diff --git a/autogpt_platform/backend/backend/copilot/bot/adapters/base.py b/autogpt_platform/backend/backend/copilot/bot/adapters/base.py index 543af72d5298..560559216b0d 100644 --- a/autogpt_platform/backend/backend/copilot/bot/adapters/base.py +++ b/autogpt_platform/backend/backend/copilot/bot/adapters/base.py @@ -107,6 +107,22 @@ class ChannelInfo(BaseModel): server_name: Optional[str] = None +class EditOutcome(Enum): + """Result of a proactive edit — see ``PlatformAdapter.edit_channel_message``. + + Distinct from a bool so the caller can surface *why* an edit didn't land: + the platform never supports edits at all (``UNSUPPORTED``), the target + message is gone or too old to touch (``NOT_FOUND``), or the platform + rejected the call for another reason — wrong author, no permission, body + too long (``FAILED``). + """ + + OK = "ok" + UNSUPPORTED = "unsupported" + NOT_FOUND = "not_found" + FAILED = "failed" + + class PostedRef(BaseModel): """Pointer to something the bot just created on the platform. @@ -399,6 +415,20 @@ async def create_channel_thread( """ ... + async def edit_channel_message( + self, channel_id: str, ref_id: str, text: str + ) -> EditOutcome: + """Edit a message this bot previously posted via ``post_channel_message``. + + ``channel_id``/``ref_id`` are exactly what that call (or + ``create_channel_thread``) returned — adapters that encode extra state + into those ids (Slack) must decode the same way as their send path. + Default: unsupported — platforms without a wired edit call (or not yet + implemented) simply inherit this rather than every caller special-casing + ``NotImplementedError``. + """ + return EditOutcome.UNSUPPORTED + class SocketAdapter(PlatformAdapter): """Adapter that owns a long-lived connection (Discord Gateway, Slack Socket diff --git a/autogpt_platform/backend/backend/copilot/bot/adapters/discord/adapter.py b/autogpt_platform/backend/backend/copilot/bot/adapters/discord/adapter.py index ef535821f753..1f089bf24c98 100644 --- a/autogpt_platform/backend/backend/copilot/bot/adapters/discord/adapter.py +++ b/autogpt_platform/backend/backend/copilot/bot/adapters/discord/adapter.py @@ -28,6 +28,7 @@ from ..base import ( ChannelInfo, ChannelType, + EditOutcome, FileAttachment, InboundAttachment, MessageCallback, @@ -146,12 +147,22 @@ async def _resolve_channel(self, channel_id: str): ``Client.get_channel`` only reads the in-memory cache, so it misses threads the bot hasn't seen since its last restart. Fall back to ``fetch_channel`` (REST) so long-lived threads keep working. + + ``channel_id`` reaches here as a caller-supplied string (a model-chosen + edit target, a raw proactive-post ID) that was never guaranteed to look + like a snowflake, so the ``int()`` conversion is inside the guarded + block rather than raising ``ValueError`` straight out to the RPC layer. """ - channel = self._client.get_channel(int(channel_id)) + try: + numeric_id = int(channel_id) + except ValueError: + logger.warning("Channel id %r is not a valid snowflake", channel_id) + return None + channel = self._client.get_channel(numeric_id) if channel is not None: return channel try: - return await self._client.fetch_channel(int(channel_id)) + return await self._client.fetch_channel(numeric_id) except (discord.NotFound, discord.Forbidden, discord.HTTPException): logger.warning("Channel %s not found or inaccessible", channel_id) return None @@ -355,6 +366,33 @@ async def create_channel_thread( ) return PostedRef(id=str(thread.id), url=thread.jump_url) + async def edit_channel_message( + self, channel_id: str, ref_id: str, text: str + ) -> EditOutcome: + channel = await self._resolve_channel(channel_id) + if channel is None or not isinstance(channel, discord.abc.Messageable): + return EditOutcome.NOT_FOUND + try: + message = await channel.fetch_message(int(ref_id)) + except ValueError: + return EditOutcome.NOT_FOUND + except discord.NotFound: + return EditOutcome.NOT_FOUND + except discord.HTTPException: + logger.exception("Failed to fetch message %s for edit", ref_id) + return EditOutcome.FAILED + rendered, allowed = _resolve_mentions( + text, await self._mentionables_for(channel, text, ()) + ) + try: + await message.edit(content=rendered, allowed_mentions=allowed) + except discord.HTTPException: + # Covers both a rejected edit (message too old/foreign author) and + # a body over Discord's cap — either way the edit did not land. + logger.exception("Failed to edit message %s", ref_id) + return EditOutcome.FAILED + return EditOutcome.OK + async def _send_chunked( self, channel: discord.abc.Messageable, text: str ) -> Optional[discord.Message]: diff --git a/autogpt_platform/backend/backend/copilot/bot/adapters/discord/adapter_test.py b/autogpt_platform/backend/backend/copilot/bot/adapters/discord/adapter_test.py index 141b886c4647..c1c9cbf7a67f 100644 --- a/autogpt_platform/backend/backend/copilot/bot/adapters/discord/adapter_test.py +++ b/autogpt_platform/backend/backend/copilot/bot/adapters/discord/adapter_test.py @@ -6,7 +6,7 @@ import discord import pytest -from backend.copilot.bot.adapters.base import FileAttachment +from backend.copilot.bot.adapters.base import EditOutcome, FileAttachment from backend.copilot.bot.adapters.discord.adapter import ( MAX_INBOUND_ATTACHMENTS, THREAD_HISTORY_CHAR_BUDGET, @@ -1260,6 +1260,16 @@ async def test_get_channel_server_id_none_when_missing(self): ) assert await adapter.get_channel_server_id("10") is None + @pytest.mark.asyncio + async def test_get_channel_server_id_none_for_non_numeric_id(self): + # A non-snowflake channel_id (a caller-chosen edit target, not + # necessarily one that passed the numeric-ID grammar check first) + # must resolve to None rather than raise ValueError out of int(). + adapter, client = _bare_adapter() + + assert await adapter.get_channel_server_id("not-a-snowflake") is None + client.get_channel.assert_not_called() + @pytest.mark.asyncio async def test_post_channel_message_returns_ref_with_url(self): adapter, client = _bare_adapter() @@ -1366,6 +1376,80 @@ async def test_create_thread_returns_ref_when_content_post_fails(self): assert ref is not None assert ref.id == "777" + @pytest.mark.asyncio + async def test_edit_channel_message_edits_in_place(self): + adapter, client = _bare_adapter() + channel = MagicMock(spec=discord.TextChannel) + message = MagicMock() + message.edit = AsyncMock() + channel.fetch_message = AsyncMock(return_value=message) + client.get_channel.return_value = channel + + outcome = await adapter.edit_channel_message("10", "999", "updated text") + + assert outcome == EditOutcome.OK + message.edit.assert_awaited_once() + assert message.edit.call_args.kwargs["content"] == "updated text" + + @pytest.mark.asyncio + async def test_edit_channel_message_not_found_when_message_missing(self): + adapter, client = _bare_adapter() + channel = MagicMock(spec=discord.TextChannel) + channel.fetch_message = AsyncMock( + side_effect=discord.NotFound(MagicMock(status=404), "gone") + ) + client.get_channel.return_value = channel + + outcome = await adapter.edit_channel_message("10", "999", "updated text") + + assert outcome == EditOutcome.NOT_FOUND + + @pytest.mark.asyncio + async def test_edit_channel_message_not_found_for_bad_ref_id(self): + adapter, client = _bare_adapter() + channel = MagicMock(spec=discord.TextChannel) + client.get_channel.return_value = channel + + outcome = await adapter.edit_channel_message("10", "not-a-number", "x") + + assert outcome == EditOutcome.NOT_FOUND + + @pytest.mark.asyncio + async def test_edit_channel_message_not_found_for_non_numeric_channel_id(self): + adapter, client = _bare_adapter() + + outcome = await adapter.edit_channel_message("not-a-snowflake", "999", "x") + + assert outcome == EditOutcome.NOT_FOUND + client.get_channel.assert_not_called() + + @pytest.mark.asyncio + async def test_edit_channel_message_failed_when_platform_rejects(self): + adapter, client = _bare_adapter() + channel = MagicMock(spec=discord.TextChannel) + message = MagicMock() + message.edit = AsyncMock( + side_effect=discord.HTTPException(MagicMock(status=403), "forbidden") + ) + channel.fetch_message = AsyncMock(return_value=message) + client.get_channel.return_value = channel + + outcome = await adapter.edit_channel_message("10", "999", "updated text") + + assert outcome == EditOutcome.FAILED + + @pytest.mark.asyncio + async def test_edit_channel_message_not_found_for_non_messageable_channel(self): + adapter, client = _bare_adapter() + client.get_channel.return_value = None + client.fetch_channel = AsyncMock( + side_effect=discord.NotFound(MagicMock(status=404), "gone") + ) + + outcome = await adapter.edit_channel_message("10", "999", "x") + + assert outcome == EditOutcome.NOT_FOUND + # ── Referenced-conversation fetch ────────────────────────────────────── diff --git a/autogpt_platform/backend/backend/copilot/bot/adapters/slack/adapter.py b/autogpt_platform/backend/backend/copilot/bot/adapters/slack/adapter.py index a8c9fb39f7b6..c6cf86b569db 100644 --- a/autogpt_platform/backend/backend/copilot/bot/adapters/slack/adapter.py +++ b/autogpt_platform/backend/backend/copilot/bot/adapters/slack/adapter.py @@ -24,12 +24,14 @@ import httpx from fastapi import FastAPI, Request, Response from fastapi.responses import JSONResponse, PlainTextResponse +from slack_sdk.errors import SlackApiError from slack_sdk.web.async_client import AsyncWebClient from backend.copilot.bot import threads from backend.copilot.bot.adapters.base import ( ChannelInfo, ChannelType, + EditOutcome, FileAttachment, MessageCallback, MessageContext, @@ -632,6 +634,29 @@ async def create_channel_thread( url=await self._permalink(team, channel, root_ts), ) + async def edit_channel_message( + self, channel_id: str, ref_id: str, text: str + ) -> EditOutcome: + team, channel, _ = _decode_target(channel_id) + client = await self._client_for(team) + if client is None: + return EditOutcome.FAILED + try: + await client.chat_update( + channel=channel, ts=ref_id, text=self.localize_markup(text) + ) + except SlackApiError as e: + if e.response.get("error") == "message_not_found": + return EditOutcome.NOT_FOUND + logger.warning( + "Slack chat.update rejected edit: %s", e.response.get("error") + ) + return EditOutcome.FAILED + except Exception: + logger.exception("Failed to edit Slack message %s", ref_id) + return EditOutcome.FAILED + return EditOutcome.OK + async def _post_chunked( self, team_id: str, channel: str, text: str, thread_ts: Optional[str] = None ) -> Optional[str]: diff --git a/autogpt_platform/backend/backend/copilot/bot/adapters/slack/adapter_test.py b/autogpt_platform/backend/backend/copilot/bot/adapters/slack/adapter_test.py index 7d4e470da1c9..91a30a54dd4a 100644 --- a/autogpt_platform/backend/backend/copilot/bot/adapters/slack/adapter_test.py +++ b/autogpt_platform/backend/backend/copilot/bot/adapters/slack/adapter_test.py @@ -7,7 +7,7 @@ import pytest from slack_sdk.errors import SlackApiError -from backend.copilot.bot.adapters.base import FileAttachment +from backend.copilot.bot.adapters.base import EditOutcome, FileAttachment from backend.data.bot_installs import BotInstallCredentials from . import config @@ -33,6 +33,7 @@ def _mock_client() -> MagicMock: client = MagicMock() client.token = "xoxb-test" client.chat_postMessage = AsyncMock(return_value={"ts": "111.222"}) + client.chat_update = AsyncMock(return_value={"ok": True}) client.chat_postEphemeral = AsyncMock() client.chat_getPermalink = AsyncMock(return_value={"permalink": "https://x/p"}) client.files_upload_v2 = AsyncMock() @@ -617,6 +618,38 @@ async def test_create_thread_encodes_target(self, adapter): async def test_rename_thread_is_noop(self, adapter): assert await adapter.rename_thread("T1|C1|9.9", "x") is False + @pytest.mark.asyncio + async def test_edit_channel_message_calls_chat_update(self, adapter): + outcome = await adapter.edit_channel_message("T1|C1|", "111.222", "updated") + + assert outcome == EditOutcome.OK + call = adapter._clients["T1"].chat_update.await_args.kwargs + assert call["channel"] == "C1" + assert call["ts"] == "111.222" + assert call["text"] == "updated" + + @pytest.mark.asyncio + async def test_edit_channel_message_not_found(self, adapter): + adapter._clients["T1"].chat_update = AsyncMock( + side_effect=SlackApiError("not found", {"error": "message_not_found"}) + ) + + outcome = await adapter.edit_channel_message("T1|C1|", "111.222", "updated") + + assert outcome == EditOutcome.NOT_FOUND + + @pytest.mark.asyncio + async def test_edit_channel_message_failed_on_other_slack_error(self, adapter): + adapter._clients["T1"].chat_update = AsyncMock( + side_effect=SlackApiError( + "edit forbidden", {"error": "cant_update_message"} + ) + ) + + outcome = await adapter.edit_channel_message("T1|C1|", "111.222", "updated") + + assert outcome == EditOutcome.FAILED + class TestChannelIdGrammar: def test_slack_ids_match(self, adapter): diff --git a/autogpt_platform/backend/backend/copilot/bot/adapters/teams/adapter.py b/autogpt_platform/backend/backend/copilot/bot/adapters/teams/adapter.py index b5757c44b189..c0124f8ac956 100644 --- a/autogpt_platform/backend/backend/copilot/bot/adapters/teams/adapter.py +++ b/autogpt_platform/backend/backend/copilot/bot/adapters/teams/adapter.py @@ -31,6 +31,7 @@ from backend.copilot.bot.adapters.base import ( ChannelInfo, ChannelType, + EditOutcome, FileAttachment, MessageCallback, MessageContext, @@ -428,6 +429,25 @@ async def create_channel_thread( body = f"**{name}**\n\n{text}" if name else text return await self.post_channel_message(channel_id, body) + async def edit_channel_message( + self, channel_id: str, ref_id: str, text: str + ) -> EditOutcome: + activity = { + "type": "message", + "text": self.localize_markup(text), + "textFormat": "markdown", + } + try: + await self._client.update_activity( + self._service_url_for(channel_id), channel_id, ref_id, activity + ) + except TeamsApiError as e: + logger.exception("Failed to edit Teams activity %s", ref_id) + if e.status_code == 404: + return EditOutcome.NOT_FOUND + return EditOutcome.FAILED + return EditOutcome.OK + async def open_dm_channel(self, platform_user_id: str) -> Optional[str]: """Create (or fetch) the bot's 1:1 conversation with a user. diff --git a/autogpt_platform/backend/backend/copilot/bot/adapters/teams/adapter_test.py b/autogpt_platform/backend/backend/copilot/bot/adapters/teams/adapter_test.py index 6ec28e7cbaf5..2336e8e03ade 100644 --- a/autogpt_platform/backend/backend/copilot/bot/adapters/teams/adapter_test.py +++ b/autogpt_platform/backend/backend/copilot/bot/adapters/teams/adapter_test.py @@ -431,6 +431,54 @@ async def test_send_link_uses_an_adaptive_card_button(app_id): assert card["content"]["actions"][0]["url"] == "https://example.com/link" +@pytest.mark.asyncio +async def test_edit_channel_message_updates_activity(app_id): + from backend.copilot.bot.adapters.base import EditOutcome + + adapter = TeamsAdapter(MagicMock()) + adapter._client.update_activity = AsyncMock(return_value=None) + + outcome = await adapter.edit_channel_message("a:chat", "activity-9", "updated text") + + assert outcome == EditOutcome.OK + args = adapter._client.update_activity.await_args.args + assert args[1] == "a:chat" + assert args[2] == "activity-9" + activity = args[3] + assert activity["type"] == "message" + assert activity["text"] == "updated text" + + +@pytest.mark.asyncio +async def test_edit_channel_message_failed_on_connector_error(app_id): + from backend.copilot.bot.adapters.base import EditOutcome + from backend.copilot.bot.adapters.teams.api_client import TeamsApiError + + adapter = TeamsAdapter(MagicMock()) + adapter._client.update_activity = AsyncMock( + side_effect=TeamsApiError("boom", status_code=500) + ) + + outcome = await adapter.edit_channel_message("a:chat", "activity-9", "updated text") + + assert outcome == EditOutcome.FAILED + + +@pytest.mark.asyncio +async def test_edit_channel_message_not_found_on_404(app_id): + from backend.copilot.bot.adapters.base import EditOutcome + from backend.copilot.bot.adapters.teams.api_client import TeamsApiError + + adapter = TeamsAdapter(MagicMock()) + adapter._client.update_activity = AsyncMock( + side_effect=TeamsApiError("not found", status_code=404) + ) + + outcome = await adapter.edit_channel_message("a:chat", "activity-9", "updated text") + + assert outcome == EditOutcome.NOT_FOUND + + @pytest.mark.asyncio async def test_non_image_file_degrades_to_a_note(app_id): from backend.copilot.bot.adapters.base import FileAttachment diff --git a/autogpt_platform/backend/backend/copilot/bot/adapters/teams/api_client.py b/autogpt_platform/backend/backend/copilot/bot/adapters/teams/api_client.py index a464deb908a4..07169b309554 100644 --- a/autogpt_platform/backend/backend/copilot/bot/adapters/teams/api_client.py +++ b/autogpt_platform/backend/backend/copilot/bot/adapters/teams/api_client.py @@ -11,6 +11,7 @@ import logging import time from typing import Any +from urllib.parse import quote import httpx @@ -25,8 +26,34 @@ _TOKEN_REFRESH_MARGIN_SECONDS = 300 +def _path_segment(value: str) -> str: + """Encode ``value`` as a single, inert URL path segment. + + For a value with no id grammar of its own — an AutoPilot-tool-supplied + ``ref_id``, not a Connector-issued id — quoting every reserved character + (``/``, ``?``, ``#``, …) stops it from splicing extra path segments into + the request, and rejecting a bare ``.``/``..`` stops a same-length + dot-segment from being normalized away by the HTTP client, either of + which could otherwise redirect the call to a different + conversation/activity than the one that was authorized. + """ + if value in (".", ".."): + raise TeamsApiError(f"invalid path segment {value!r}") + return quote(value, safe="") + + class TeamsApiError(Exception): - """A Bot Connector call failed.""" + """A Bot Connector call failed. + + ``status_code`` is the HTTP status the Connector responded with, or + ``None`` for errors raised before a response arrived (e.g. an untrusted + ``serviceUrl``) — callers that need to distinguish "not found" from other + rejections (edit outcomes) branch on this rather than parsing the message. + """ + + def __init__(self, message: str, status_code: int | None = None) -> None: + super().__init__(message) + self.status_code = status_code class TeamsClient: @@ -50,6 +77,30 @@ async def send_activity( activity, ) + async def update_activity( + self, + service_url: str, + conversation_id: str, + activity_id: str, + activity: dict[str, Any], + ) -> None: + """Replace a previously sent activity's content in place. + + Unlike ``conversation_id`` (always a real Teams-issued id the caller + re-derived and matched before getting here — see + ``outbound.edit_message``'s DM authorization), ``activity_id`` is an + AutoPilot-tool-supplied ``ref_id`` with no such constraint, so it's + the one encoded as an inert path segment here: unescaped, a `/` + or a `..` segment in it could redirect this PUT to a different + conversation/activity than the one that was authorized. + """ + await self._request( + "PUT", + service_url, + f"v3/conversations/{conversation_id}/activities/{_path_segment(activity_id)}", + activity, + ) + async def create_conversation( self, service_url: str, payload: dict[str, Any] ) -> str | None: @@ -98,7 +149,8 @@ async def _request( response = await self._http.request(method, url, json=payload, headers=headers) if response.status_code >= 400: raise TeamsApiError( - f"{method} {path} failed ({response.status_code}): {response.text[:300]}" + f"{method} {path} failed ({response.status_code}): {response.text[:300]}", + status_code=response.status_code, ) if not response.content: return None diff --git a/autogpt_platform/backend/backend/copilot/bot/adapters/teams/api_client_test.py b/autogpt_platform/backend/backend/copilot/bot/adapters/teams/api_client_test.py index 1a28ffa3724f..6a0e8d8db68f 100644 --- a/autogpt_platform/backend/backend/copilot/bot/adapters/teams/api_client_test.py +++ b/autogpt_platform/backend/backend/copilot/bot/adapters/teams/api_client_test.py @@ -166,3 +166,67 @@ async def test_the_playground_sends_no_bearer(): client = TeamsClient() with patch(f"{_CONFIG_PATH}.allow_unverified_requests", return_value=True): assert await client.bearer_headers() == {} + + +def _ok_response(): + response = MagicMock() + response.status_code = 200 + response.content = b"{}" + response.json.return_value = {} + return response + + +def _authed_client() -> TeamsClient: + client = TeamsClient() + client._http = MagicMock() + client._token = "tok" + client._token_expires_at = time.monotonic() + 600 + return client + + +@pytest.mark.asyncio +async def test_update_activity_builds_the_expected_url(): + client = _authed_client() + client._http.request = AsyncMock(return_value=_ok_response()) + app_id, password, tenant, unverified = _creds() + with app_id, password, tenant, unverified: + await client.update_activity(_ALLOWED, "19:conv", "act-1", {"type": "message"}) + + method, url = client._http.request.await_args.args + assert method == "PUT" + assert url == f"{_ALLOWED.rstrip('/')}/v3/conversations/19:conv/activities/act-1" + + +@pytest.mark.asyncio +async def test_update_activity_encodes_a_slash_in_activity_id(): + # activity_id is an AutoPilot-tool-supplied ref_id with no id grammar of + # its own — a raw "/" must not be able to splice in an extra path segment + # and redirect the PUT to a different (unauthorized) conversation. + client = _authed_client() + client._http.request = AsyncMock(return_value=_ok_response()) + app_id, password, tenant, unverified = _creds() + with app_id, password, tenant, unverified: + await client.update_activity( + _ALLOWED, "19:conv", "../../conversations/other/activities/x", {} + ) + + _, url = client._http.request.await_args.args + # The malicious segment is fully percent-encoded, so it stays one inert + # path component instead of being normalized into a different path. + assert url.startswith( + f"{_ALLOWED.rstrip('/')}/v3/conversations/19:conv/activities/" + ) + assert "/conversations/other/" not in url + assert "%2F" in url + + +@pytest.mark.asyncio +@pytest.mark.parametrize("activity_id", [".", ".."]) +async def test_update_activity_rejects_bare_dot_segments(activity_id): + client = _authed_client() + client._http.request = AsyncMock(return_value=_ok_response()) + app_id, password, tenant, unverified = _creds() + with app_id, password, tenant, unverified, pytest.raises(TeamsApiError): + await client.update_activity(_ALLOWED, "19:conv", activity_id, {}) + + client._http.request.assert_not_awaited() diff --git a/autogpt_platform/backend/backend/copilot/bot/adapters/telegram/adapter.py b/autogpt_platform/backend/backend/copilot/bot/adapters/telegram/adapter.py index eb1fc8a290c0..a5fb46914b69 100644 --- a/autogpt_platform/backend/backend/copilot/bot/adapters/telegram/adapter.py +++ b/autogpt_platform/backend/backend/copilot/bot/adapters/telegram/adapter.py @@ -26,6 +26,7 @@ from backend.copilot.bot.adapters.base import ( ChannelInfo, ChannelType, + EditOutcome, FileAttachment, MessageCallback, MessageContext, @@ -41,7 +42,7 @@ from backend.copilot.bot.text import iter_chunks, resolve_mentions from . import commands, config -from .api_client import TelegramClient +from .api_client import TelegramAPIError, TelegramClient from .targets import decode_target as _decode_target from .targets import encode_target as _encode_target from .text import to_html @@ -531,6 +532,32 @@ async def create_channel_thread( return None return PostedRef(id=channel_id, url=posted.url) + async def edit_channel_message( + self, channel_id: str, ref_id: str, text: str + ) -> EditOutcome: + chat_id, _ = _decode_target(channel_id) + try: + message_id = int(ref_id) + except ValueError: + return EditOutcome.NOT_FOUND + try: + await self._client.call( + "editMessageText", + chat_id=chat_id, + message_id=message_id, + text=self.localize_markup(text), + parse_mode="HTML", + ) + except TelegramAPIError as e: + if "not found" in str(e).lower(): + return EditOutcome.NOT_FOUND + logger.warning("Telegram editMessageText rejected edit: %s", e) + return EditOutcome.FAILED + except Exception: + logger.exception("Failed to edit Telegram message %s", ref_id) + return EditOutcome.FAILED + return EditOutcome.OK + # -- Helpers -- async def _bot_identity(self) -> tuple[str, str]: diff --git a/autogpt_platform/backend/backend/copilot/bot/adapters/telegram/adapter_test.py b/autogpt_platform/backend/backend/copilot/bot/adapters/telegram/adapter_test.py index 10d35c168126..a8c274947694 100644 --- a/autogpt_platform/backend/backend/copilot/bot/adapters/telegram/adapter_test.py +++ b/autogpt_platform/backend/backend/copilot/bot/adapters/telegram/adapter_test.py @@ -6,7 +6,11 @@ import pytest -from backend.copilot.bot.adapters.base import FileAttachment, StreamDraftOutcome +from backend.copilot.bot.adapters.base import ( + EditOutcome, + FileAttachment, + StreamDraftOutcome, +) from backend.copilot.bot.adapters.telegram.api_client import TelegramAPIError from .adapter import ( @@ -461,3 +465,51 @@ async def test_entity_dense_chunks_respect_the_parsed_length_cap(self): html.unescape(re.sub(r"<[^>]+>", "", c.kwargs["text"])) for c in calls ) assert joined.count("Tom") == 200 # nothing dropped across the chunks + + +class TestEditChannelMessage: + @pytest.mark.asyncio + async def test_edit_channel_message_calls_edit_message_text(self): + a = _adapter() + + outcome = await a.edit_channel_message("123", "77", "updated text") + + assert outcome == EditOutcome.OK + assert a._client.call.call_args.args == ("editMessageText",) + kwargs = a._client.call.call_args.kwargs + assert kwargs["chat_id"] == "123" + assert kwargs["message_id"] == 77 + assert kwargs["text"] == "updated text" + + @pytest.mark.asyncio + async def test_edit_channel_message_not_found(self): + a = _adapter() + a._client.call = AsyncMock( + side_effect=TelegramAPIError( + "editMessageText failed: message to edit not found" + ) + ) + + outcome = await a.edit_channel_message("123", "77", "updated text") + + assert outcome == EditOutcome.NOT_FOUND + + @pytest.mark.asyncio + async def test_edit_channel_message_failed_on_other_error(self): + a = _adapter() + a._client.call = AsyncMock( + side_effect=TelegramAPIError("editMessageText failed: message is too old") + ) + + outcome = await a.edit_channel_message("123", "77", "updated text") + + assert outcome == EditOutcome.FAILED + + @pytest.mark.asyncio + async def test_edit_channel_message_not_found_for_bad_ref_id(self): + a = _adapter() + + outcome = await a.edit_channel_message("123", "not-a-number", "updated text") + + assert outcome == EditOutcome.NOT_FOUND + a._client.call.assert_not_awaited() diff --git a/autogpt_platform/backend/backend/copilot/bot/app.py b/autogpt_platform/backend/backend/copilot/bot/app.py index d978e41fc18b..4576071ac1d8 100644 --- a/autogpt_platform/backend/backend/copilot/bot/app.py +++ b/autogpt_platform/backend/backend/copilot/bot/app.py @@ -6,6 +6,7 @@ import asyncio import logging from concurrent.futures import Future +from typing import Literal from backend.platform_linking.models import Platform from backend.util.service import ( @@ -23,7 +24,7 @@ from .adapters.discord.adapter import DiscordAdapter from .bot_backend import BotBackend from .handler import MessageHandler -from .outbound import DeliveryResult +from .outbound import DeliveryResult, EditResult from .webhook_routes import build_webhook_adapters logger = logging.getLogger(__name__) @@ -200,6 +201,28 @@ async def create_thread_in_channel( adapter, api, platform.value, user_id, channel, thread_name, content ) + @expose + async def edit_message_in_channel( + self, + platform: Platform, + user_id: str, + target: Literal["channel", "dm"], + channel_id: str, + ref_id: str, + content: str, + ) -> EditResult: + """Edit a message ``user_id`` previously posted via ``send_message_to_channel`` + / ``send_dm_to_user`` / ``create_thread_in_channel``. + + ``channel_id``/``ref_id`` must be exactly what that earlier call + returned — authorization re-derives the expected channel from the + user's links rather than trusting the caller's ``channel_id``. + """ + adapter, api = self._require(platform) + return await outbound.edit_message( + adapter, api, platform.value, user_id, target, channel_id, ref_id, content + ) + class CoPilotChatBridgeClient(AppServiceClient): @classmethod @@ -214,6 +237,9 @@ def get_service_type(cls): create_thread_in_channel = endpoint_to_async( CoPilotChatBridge.create_thread_in_channel ) + edit_message_in_channel = endpoint_to_async( + CoPilotChatBridge.edit_message_in_channel + ) def _build_socket_adapters(api: BotBackend) -> list[SocketAdapter]: diff --git a/autogpt_platform/backend/backend/copilot/bot/outbound.py b/autogpt_platform/backend/backend/copilot/bot/outbound.py index b0d5713dbf7c..29d5ef29f826 100644 --- a/autogpt_platform/backend/backend/copilot/bot/outbound.py +++ b/autogpt_platform/backend/backend/copilot/bot/outbound.py @@ -19,11 +19,18 @@ from pydantic import BaseModel -from backend.copilot.bot.adapters.base import ChannelInfo, PlatformAdapter +from backend.copilot.bot.adapters.base import ChannelInfo, EditOutcome, PlatformAdapter from backend.copilot.bot.bot_backend import BotBackend logger = logging.getLogger(__name__) +# Maps an adapter's EditOutcome to the stable error code a tool/LLM can relay. +_EDIT_OUTCOME_ERRORS: dict[EditOutcome, str] = { + EditOutcome.UNSUPPORTED: "edit_unsupported", + EditOutcome.NOT_FOUND: "message_not_found", + EditOutcome.FAILED: "edit_failed", +} + class DeliveryResult(BaseModel): """Outcome of a proactive post, shaped for a tool/LLM to relay. @@ -41,6 +48,13 @@ class DeliveryResult(BaseModel): error: Optional[str] = None +class EditResult(BaseModel): + """Outcome of a proactive edit, shaped for a tool/LLM to relay.""" + + ok: bool + error: Optional[str] = None + + async def list_channels( adapter: PlatformAdapter, api: BotBackend, @@ -142,6 +156,52 @@ async def create_thread( ) +async def edit_message( + adapter: PlatformAdapter, + api: BotBackend, + platform: str, + user_id: str, + target: Literal["channel", "dm"], + channel_id: str, + ref_id: str, + content: str, +) -> EditResult: + """Edit a message previously posted via ``deliver_message``/``deliver_dm``. + + ``channel_id``/``ref_id`` are the values that call returned — never a + caller-chosen channel name — so authorization here re-derives the + expected channel from the user's links and requires it to match, exactly + like ``_resolve_target`` does for a raw id. This closes the same hole a + naive "trust the channel_id the model sent back" implementation would + open: a channel_id alone doesn't prove the calling user's account is the + one linked to it. + """ + if not content or not content.strip(): + return EditResult(ok=False, error="empty_content") + + if target == "dm": + platform_user_id = await api.get_dm_user_id(platform, user_id) + if platform_user_id is None: + return EditResult(ok=False, error="no_dm_link") + expected_channel_id = await adapter.open_dm_channel(platform_user_id) + if expected_channel_id is None or expected_channel_id != channel_id: + return EditResult(ok=False, error="not_authorized") + else: + server_ids = tuple(await api.list_linked_server_ids(platform, user_id)) + if not server_ids: + return EditResult(ok=False, error="no_linked_servers") + guild_id = await adapter.get_channel_server_id(channel_id) + if guild_id is None: + return EditResult(ok=False, error="channel_not_found") + if guild_id not in server_ids: + return EditResult(ok=False, error="not_authorized") + + outcome = await adapter.edit_channel_message(channel_id, ref_id, content) + if outcome is EditOutcome.OK: + return EditResult(ok=True) + return EditResult(ok=False, error=_EDIT_OUTCOME_ERRORS[outcome]) + + async def _resolve_target( adapter: PlatformAdapter, api: BotBackend, diff --git a/autogpt_platform/backend/backend/copilot/bot/outbound_test.py b/autogpt_platform/backend/backend/copilot/bot/outbound_test.py index 86f33da73f61..c121e7535e81 100644 --- a/autogpt_platform/backend/backend/copilot/bot/outbound_test.py +++ b/autogpt_platform/backend/backend/copilot/bot/outbound_test.py @@ -6,7 +6,7 @@ import pytest from backend.copilot.bot import outbound -from backend.copilot.bot.adapters.base import ChannelInfo, PostedRef +from backend.copilot.bot.adapters.base import ChannelInfo, EditOutcome, PostedRef def _api(server_ids: list[str]) -> AsyncMock: @@ -22,6 +22,7 @@ def _adapter( posted: PostedRef | None = None, thread: PostedRef | None = None, dm_channel: str | None = None, + edit_outcome: EditOutcome = EditOutcome.OK, ) -> AsyncMock: adapter = AsyncMock() # Sync classifier — mirrors Discord's numeric-snowflake grammar so @@ -34,6 +35,7 @@ def _adapter( adapter.post_channel_message.return_value = posted adapter.create_channel_thread.return_value = thread adapter.open_dm_channel.return_value = dm_channel + adapter.edit_channel_message.return_value = edit_outcome return adapter @@ -266,3 +268,124 @@ async def test_list_channels_drops_unlinked_server_channels(): ) result = await outbound.list_channels(adapter, _api(["g1"]), "discord", "user-1") assert [c.id for c in result] == ["10"] + + +@pytest.mark.asyncio +async def test_edit_message_channel_happy_path(): + adapter = _adapter(channel_server="g1") + result = await outbound.edit_message( + adapter, _api(["g1"]), "discord", "user-1", "channel", "42", "100", "updated" + ) + assert result.ok is True + assert result.error is None + adapter.edit_channel_message.assert_awaited_once_with("42", "100", "updated") + + +@pytest.mark.asyncio +async def test_edit_message_channel_in_unlinked_server_is_rejected(): + adapter = _adapter(channel_server="other-guild") + result = await outbound.edit_message( + adapter, _api(["g1"]), "discord", "user-1", "channel", "42", "100", "updated" + ) + assert result.ok is False + assert result.error == "not_authorized" + adapter.edit_channel_message.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_edit_message_channel_unknown_id_is_not_found(): + adapter = _adapter(channel_server=None) + result = await outbound.edit_message( + adapter, _api(["g1"]), "discord", "user-1", "channel", "42", "100", "updated" + ) + assert result.ok is False + assert result.error == "channel_not_found" + + +@pytest.mark.asyncio +async def test_edit_message_channel_no_linked_servers(): + adapter = _adapter() + result = await outbound.edit_message( + adapter, _api([]), "discord", "user-1", "channel", "42", "100", "updated" + ) + assert result.ok is False + assert result.error == "no_linked_servers" + adapter.get_channel_server_id.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_edit_message_dm_happy_path(): + adapter = _adapter(dm_channel="dm-42") + result = await outbound.edit_message( + adapter, _dm_api("pu1"), "discord", "u1", "dm", "dm-42", "100", "updated" + ) + assert result.ok is True + adapter.open_dm_channel.assert_awaited_once_with("pu1") + adapter.edit_channel_message.assert_awaited_once_with("dm-42", "100", "updated") + + +@pytest.mark.asyncio +async def test_edit_message_dm_without_link_is_rejected(): + adapter = _adapter(dm_channel="dm-42") + result = await outbound.edit_message( + adapter, _dm_api(None), "discord", "u1", "dm", "dm-42", "100", "updated" + ) + assert result.ok is False + assert result.error == "no_dm_link" + adapter.edit_channel_message.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_edit_message_dm_channel_id_mismatch_is_rejected(): + # A caller-supplied channel_id that doesn't match this user's own DM + # channel must never be trusted, even though it "looks like" a DM id — + # this is the authorization check the caller-supplied id can't skip. + adapter = _adapter(dm_channel="dm-42") + result = await outbound.edit_message( + adapter, _dm_api("pu1"), "discord", "u1", "dm", "someone-elses-dm", "100", "x" + ) + assert result.ok is False + assert result.error == "not_authorized" + adapter.edit_channel_message.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_edit_message_empty_content_is_distinct_error(): + adapter = _adapter(channel_server="g1") + result = await outbound.edit_message( + adapter, _api(["g1"]), "discord", "user-1", "channel", "42", "100", " " + ) + assert result.ok is False + assert result.error == "empty_content" + adapter.get_channel_server_id.assert_not_awaited() + adapter.edit_channel_message.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_edit_message_unsupported_platform_is_surfaced(): + adapter = _adapter(channel_server="g1", edit_outcome=EditOutcome.UNSUPPORTED) + result = await outbound.edit_message( + adapter, _api(["g1"]), "teams", "user-1", "channel", "42", "100", "updated" + ) + assert result.ok is False + assert result.error == "edit_unsupported" + + +@pytest.mark.asyncio +async def test_edit_message_not_found_is_surfaced(): + adapter = _adapter(channel_server="g1", edit_outcome=EditOutcome.NOT_FOUND) + result = await outbound.edit_message( + adapter, _api(["g1"]), "discord", "user-1", "channel", "42", "100", "updated" + ) + assert result.ok is False + assert result.error == "message_not_found" + + +@pytest.mark.asyncio +async def test_edit_message_failed_is_surfaced(): + adapter = _adapter(channel_server="g1", edit_outcome=EditOutcome.FAILED) + result = await outbound.edit_message( + adapter, _api(["g1"]), "discord", "user-1", "channel", "42", "100", "updated" + ) + assert result.ok is False + assert result.error == "edit_failed" diff --git a/autogpt_platform/backend/backend/copilot/permissions.py b/autogpt_platform/backend/backend/copilot/permissions.py index eb20b191601c..dfeafafe6e31 100644 --- a/autogpt_platform/backend/backend/copilot/permissions.py +++ b/autogpt_platform/backend/backend/copilot/permissions.py @@ -92,6 +92,7 @@ "delete_skill", "delete_workspace_file", "edit_agent", + "edit_chat_platform_message", "enter_agent_building_mode", "find_agent", "find_block", diff --git a/autogpt_platform/backend/backend/copilot/tools/__init__.py b/autogpt_platform/backend/backend/copilot/tools/__init__.py index e56266dd05fd..673b1a566776 100644 --- a/autogpt_platform/backend/backend/copilot/tools/__init__.py +++ b/autogpt_platform/backend/backend/copilot/tools/__init__.py @@ -15,7 +15,11 @@ from .ask_question import AskQuestionTool from .base import BaseTool from .bash_exec import BashExecTool -from .chat_platform import ListChatPlatformChannelsTool, PostToChatPlatformTool +from .chat_platform import ( + EditChatPlatformMessageTool, + ListChatPlatformChannelsTool, + PostToChatPlatformTool, +) from .confirm_expert_change import ConfirmExpertChangeTool from .connect_integration import ConnectIntegrationTool from .continue_run_block import ContinueRunBlockTool @@ -110,6 +114,7 @@ "schedule_followup": ScheduleFollowupTool(), # Proactive chat-platform output (post message / open thread on user's behalf) "post_to_chat_platform": PostToChatPlatformTool(), + "edit_chat_platform_message": EditChatPlatformMessageTool(), "list_chat_platform_channels": ListChatPlatformChannelsTool(), # Trigger management (parent agent → its triggers) "list_agent_triggers": ListAgentTriggersTool(), diff --git a/autogpt_platform/backend/backend/copilot/tools/chat_platform.py b/autogpt_platform/backend/backend/copilot/tools/chat_platform.py index 89784a32a6a4..0e120082fdfb 100644 --- a/autogpt_platform/backend/backend/copilot/tools/chat_platform.py +++ b/autogpt_platform/backend/backend/copilot/tools/chat_platform.py @@ -21,7 +21,7 @@ import logging from functools import lru_cache -from typing import Any +from typing import Any, Literal, cast from backend.copilot.model import ChatSession from backend.platform_linking.models import Platform @@ -32,6 +32,7 @@ from .models import ( ChatPlatformChannelListResponse, ChatPlatformChannelSummary, + ChatPlatformEditedResponse, ChatPlatformPostedResponse, ErrorResponse, ToolResponseBase, @@ -93,6 +94,17 @@ "dm_unavailable": ( "The bot couldn't open a DM with the user's linked account on that " "platform." ), + "edit_unsupported": ( + "This platform doesn't support editing a message after it's " "posted." + ), + "message_not_found": ( + "That message could not be found — it may have been deleted or is too " + "old to edit." + ), + "edit_failed": ( + "The platform rejected the edit — the bot may lack permission, or the " + "message wasn't posted by the bot." + ), } @@ -203,7 +215,9 @@ def description(self) -> str: "target='dm' only; its channels cannot be posted to yet. Pair " "with schedule_followup for recurring posts; call " "list_chat_platform_channels if a Discord/Slack channel won't " - "resolve." + "resolve. To edit what was posted, call " + "edit_chat_platform_message with the channel_id and ref_id this " + "tool returns." ) @property @@ -385,6 +399,164 @@ async def _execute( ) +class EditChatPlatformMessageTool(BaseTool): + """Edit a message the bot previously posted via ``post_to_chat_platform``.""" + + @property + def name(self) -> str: + return "edit_chat_platform_message" + + @property + def description(self) -> str: + return ( + "Edit a message the bot previously sent with post_to_chat_platform " + "on Discord, Slack, Telegram or Microsoft Teams. Pass the same " + "`channel_id` and `ref_id` that call returned (and the same " + "`target`/`platform` it used) along with the new `content` — the " + "old content is replaced entirely. Only messages the bot itself " + "posted, in a channel or DM already linked to this account, can " + "be edited; a failure (message too old, deleted, or the platform " + "rejecting the edit) is always reported, never silent." + ) + + @property + def requires_auth(self) -> bool: + return True + + @property + def is_available(self) -> bool: + return _any_chat_platform_configured() + + @property + def parameters(self) -> dict[str, Any]: + return { + "type": "object", + "properties": { + "platform": _platform_param(), + "target": { + "type": "string", + "enum": ["channel", "dm"], + "description": ( + "Must match the `target` used in the original " + "post_to_chat_platform call." + ), + }, + "channel_id": { + "type": "string", + "description": ( + "The `channel_id` post_to_chat_platform returned for " + "the message being edited." + ), + }, + "ref_id": { + "type": "string", + "description": ( + "The `ref_id` post_to_chat_platform returned for the " + "message being edited." + ), + }, + "content": { + "type": "string", + "description": "New message body, replacing the original content.", + }, + }, + "required": ["channel_id", "ref_id", "content"], + } + + @staticmethod + def _validate_params(session_id: str | None, **kwargs) -> ErrorResponse | None: + _platform, platform_name = _resolve_platform(kwargs.get("platform")) + if _platform is None: + return ErrorResponse( + message=f"Unsupported platform '{platform_name}'.", + error="unsupported_platform", + session_id=session_id, + ) + target: str = kwargs.get("target") or _default_target(platform_name) + if target not in ("channel", "dm"): + return ErrorResponse( + message="`target` must be 'channel' or 'dm'.", + error="invalid_target", + session_id=session_id, + ) + channel_id = kwargs.get("channel_id") + if not channel_id or not str(channel_id).strip(): + return ErrorResponse( + message="`channel_id` is required.", + error="missing_channel_id", + session_id=session_id, + ) + ref_id = kwargs.get("ref_id") + if not ref_id or not str(ref_id).strip(): + return ErrorResponse( + message="`ref_id` is required.", + error="missing_ref_id", + session_id=session_id, + ) + content = kwargs.get("content") + if not content or not content.strip(): + return ErrorResponse( + message="`content` is required.", + error="missing_content", + session_id=session_id, + ) + return None + + async def _execute( + self, + user_id: str | None, + session: ChatSession, + **kwargs, + ) -> ToolResponseBase: + session_id = session.session_id if session else None + if not user_id: + return ErrorResponse( + message="Authentication required.", + error="auth_required", + session_id=session_id, + ) + invalid = self._validate_params(session_id, **kwargs) + if invalid is not None: + return invalid + + platform, platform_name = _resolve_platform(kwargs.get("platform")) + if platform is None: # already validated; narrows the type + return ErrorResponse( + message=f"Unsupported platform '{platform_name}'.", + error="unsupported_platform", + session_id=session_id, + ) + target_value: str = kwargs.get("target") or _default_target(platform_name) + # Already validated to be one of these two literals above. + target = cast(Literal["channel", "dm"], target_value) + channel_id = str(kwargs["channel_id"]) + ref_id = str(kwargs["ref_id"]) + content: str = kwargs["content"] + + client = get_copilot_chat_bridge_client() + result = await client.edit_message_in_channel( + platform=platform, + user_id=user_id, + target=target, + channel_id=channel_id, + ref_id=ref_id, + content=content, + ) + if not result.ok: + return ErrorResponse( + message=_error_message(result.error, platform_name), + error=result.error or "chat_platform_edit_failed", + session_id=session_id, + ) + return ChatPlatformEditedResponse( + message=f"Edited the message on {platform_name}.", + platform=platform_name, + channel_id=channel_id, + ref_id=ref_id, + session_id=session_id, + ) + + class ListChatPlatformChannelsTool(BaseTool): """List channels the bot can post to across the user's linked servers.""" diff --git a/autogpt_platform/backend/backend/copilot/tools/chat_platform_test.py b/autogpt_platform/backend/backend/copilot/tools/chat_platform_test.py index c29e020f5548..90ffd8c9fc14 100644 --- a/autogpt_platform/backend/backend/copilot/tools/chat_platform_test.py +++ b/autogpt_platform/backend/backend/copilot/tools/chat_platform_test.py @@ -5,14 +5,16 @@ import pytest from backend.copilot.bot.adapters.base import ChannelInfo -from backend.copilot.bot.outbound import DeliveryResult +from backend.copilot.bot.outbound import DeliveryResult, EditResult from backend.copilot.tools.chat_platform import ( + EditChatPlatformMessageTool, ListChatPlatformChannelsTool, PostToChatPlatformTool, _any_chat_platform_configured, ) from backend.copilot.tools.models import ( ChatPlatformChannelListResponse, + ChatPlatformEditedResponse, ChatPlatformPostedResponse, ErrorResponse, ) @@ -34,6 +36,7 @@ def _bridge() -> MagicMock: bridge.create_thread_in_channel = AsyncMock() bridge.send_dm_to_user = AsyncMock() bridge.list_channels = AsyncMock() + bridge.edit_message_in_channel = AsyncMock() return bridge @@ -315,6 +318,151 @@ async def test_post_unavailable_without_token(): assert PostToChatPlatformTool().is_available is True +# ── EditChatPlatformMessageTool ───────────────────────────────────── + + +@pytest.mark.asyncio +async def test_edit_requires_auth(session): + result = await EditChatPlatformMessageTool()._execute( + user_id=None, session=session, channel_id="42", ref_id="100", content="hi" + ) + assert isinstance(result, ErrorResponse) + assert result.error == "auth_required" + + +@pytest.mark.asyncio +async def test_edit_missing_channel_id(session): + result = await EditChatPlatformMessageTool()._execute( + user_id=_USER, session=session, channel_id=" ", ref_id="100", content="hi" + ) + assert isinstance(result, ErrorResponse) + assert result.error == "missing_channel_id" + + +@pytest.mark.asyncio +async def test_edit_missing_ref_id(session): + result = await EditChatPlatformMessageTool()._execute( + user_id=_USER, session=session, channel_id="42", ref_id="", content="hi" + ) + assert isinstance(result, ErrorResponse) + assert result.error == "missing_ref_id" + + +@pytest.mark.asyncio +async def test_edit_missing_content(session): + result = await EditChatPlatformMessageTool()._execute( + user_id=_USER, session=session, channel_id="42", ref_id="100", content=" " + ) + assert isinstance(result, ErrorResponse) + assert result.error == "missing_content" + + +@pytest.mark.asyncio +async def test_edit_unsupported_platform(session): + result = await EditChatPlatformMessageTool()._execute( + user_id=_USER, + session=session, + platform="myspace", + channel_id="42", + ref_id="100", + content="hi", + ) + assert isinstance(result, ErrorResponse) + assert result.error == "unsupported_platform" + + +@pytest.mark.asyncio +async def test_edit_happy_path_defaults_to_discord(session): + bridge = _bridge() + bridge.edit_message_in_channel.return_value = EditResult(ok=True) + with patch(f"{_PATH}.get_copilot_chat_bridge_client", return_value=bridge): + result = await EditChatPlatformMessageTool()._execute( + user_id=_USER, + session=session, + channel_id="42", + ref_id="100", + content="updated", + ) + assert isinstance(result, ChatPlatformEditedResponse) + assert result.platform == "discord" + assert result.channel_id == "42" + assert result.ref_id == "100" + call = bridge.edit_message_in_channel.await_args.kwargs + assert call["platform"].value == "DISCORD" + assert call["target"] == "channel" + assert call["channel_id"] == "42" + assert call["ref_id"] == "100" + assert call["content"] == "updated" + + +@pytest.mark.asyncio +async def test_edit_dm_defaults_target_for_teams(session): + bridge = _bridge() + bridge.edit_message_in_channel.return_value = EditResult(ok=True) + with patch(f"{_PATH}.get_copilot_chat_bridge_client", return_value=bridge): + result = await EditChatPlatformMessageTool()._execute( + user_id=_USER, + session=session, + platform="teams", + channel_id="a:chat", + ref_id="activity-9", + content="updated", + ) + assert isinstance(result, ChatPlatformEditedResponse) + assert bridge.edit_message_in_channel.await_args.kwargs["target"] == "dm" + + +@pytest.mark.asyncio +async def test_edit_maps_not_authorized_error(session): + bridge = _bridge() + bridge.edit_message_in_channel.return_value = EditResult( + ok=False, error="not_authorized" + ) + with patch(f"{_PATH}.get_copilot_chat_bridge_client", return_value=bridge): + result = await EditChatPlatformMessageTool()._execute( + user_id=_USER, session=session, channel_id="999", ref_id="100", content="hi" + ) + assert isinstance(result, ErrorResponse) + assert result.error == "not_authorized" + + +@pytest.mark.asyncio +async def test_edit_maps_message_not_found_error(session): + bridge = _bridge() + bridge.edit_message_in_channel.return_value = EditResult( + ok=False, error="message_not_found" + ) + with patch(f"{_PATH}.get_copilot_chat_bridge_client", return_value=bridge): + result = await EditChatPlatformMessageTool()._execute( + user_id=_USER, session=session, channel_id="42", ref_id="100", content="hi" + ) + assert isinstance(result, ErrorResponse) + assert result.error == "message_not_found" + assert "deleted" in result.message + + +@pytest.mark.asyncio +async def test_edit_maps_edit_unsupported_error(session): + bridge = _bridge() + bridge.edit_message_in_channel.return_value = EditResult( + ok=False, error="edit_unsupported" + ) + with patch(f"{_PATH}.get_copilot_chat_bridge_client", return_value=bridge): + result = await EditChatPlatformMessageTool()._execute( + user_id=_USER, session=session, channel_id="42", ref_id="100", content="hi" + ) + assert isinstance(result, ErrorResponse) + assert result.error == "edit_unsupported" + + +@pytest.mark.asyncio +async def test_edit_unavailable_without_token(): + with patch(f"{_PATH}._any_chat_platform_configured", return_value=False): + assert EditChatPlatformMessageTool().is_available is False + with patch(f"{_PATH}._any_chat_platform_configured", return_value=True): + assert EditChatPlatformMessageTool().is_available is True + + # ── ListChatPlatformChannelsTool ─────────────────────────────────── diff --git a/autogpt_platform/backend/backend/copilot/tools/models.py b/autogpt_platform/backend/backend/copilot/tools/models.py index 3812b8f6ab09..cc455666998b 100644 --- a/autogpt_platform/backend/backend/copilot/tools/models.py +++ b/autogpt_platform/backend/backend/copilot/tools/models.py @@ -113,9 +113,10 @@ class ResponseType(str, Enum): # Platform info PLATFORM_INFO = "platform_info" - # Chat-platform proactive output (post message / create thread) + # Chat-platform proactive output (post message / create thread / edit) CHAT_PLATFORM_CHANNEL_LIST = "chat_platform_channel_list" CHAT_PLATFORM_POSTED = "chat_platform_posted" + CHAT_PLATFORM_EDITED = "chat_platform_edited" # Skills (self-distilled procedure registry) SKILL_STORED = "skill_stored" @@ -1246,3 +1247,12 @@ class ChatPlatformPostedResponse(ToolResponseBase): channel_id: str ref_id: str | None = None url: str | None = None + + +class ChatPlatformEditedResponse(ToolResponseBase): + """Response after the bot edits a message it previously posted.""" + + type: ResponseType = ResponseType.CHAT_PLATFORM_EDITED + platform: str + channel_id: str + ref_id: str diff --git a/autogpt_platform/backend/backend/copilot/tools/tool_schema_test.py b/autogpt_platform/backend/backend/copilot/tools/tool_schema_test.py index aff25402e43d..b7077ade5653 100644 --- a/autogpt_platform/backend/backend/copilot/tools/tool_schema_test.py +++ b/autogpt_platform/backend/backend/copilot/tools/tool_schema_test.py @@ -112,7 +112,12 @@ # Bumped 59_000 -> 61_000 for update_expert (the Autopilot-side soul edit, # same confirm gate) and raise_expert's color palette enum + persona-name # guidance. Merged registry measures 59625 chars; ~1.4k headroom. -_CHAR_BUDGET = 61_000 +# Bumped 61_000 -> 63_000 for SECRT-2605: the new edit_chat_platform_message +# tool (mirrors post_to_chat_platform's platform/target enums plus +# channel_id/ref_id/content params) and a short addition to +# post_to_chat_platform's description pointing at it. Registry measures +# 62218 chars; ~800 headroom for wording tweaks. +_CHAR_BUDGET = 63_000 @pytest.fixture(scope="module") diff --git a/autogpt_platform/frontend/src/app/api/openapi.json b/autogpt_platform/frontend/src/app/api/openapi.json index 842cc239eae8..ee1c042a6879 100644 --- a/autogpt_platform/frontend/src/app/api/openapi.json +++ b/autogpt_platform/frontend/src/app/api/openapi.json @@ -25747,6 +25747,7 @@ "platform_info", "chat_platform_channel_list", "chat_platform_posted", + "chat_platform_edited", "skill_stored", "skill_loaded", "skill_deleted",