From 19982a6ee232110664add682d93250c6cb1e91e9 Mon Sep 17 00:00:00 2001 From: infinitycrew39 Date: Sun, 21 Jun 2026 08:31:46 +0700 Subject: [PATCH 1/4] test(telegram): cover thread fallback when badrequest is non-network --- .../gateway/test_telegram_thread_fallback.py | 33 +++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/tests/gateway/test_telegram_thread_fallback.py b/tests/gateway/test_telegram_thread_fallback.py index 036d27e771bf..b4219e4f6e3a 100644 --- a/tests/gateway/test_telegram_thread_fallback.py +++ b/tests/gateway/test_telegram_thread_fallback.py @@ -48,6 +48,11 @@ def __init__(self, seconds): self.retry_after = seconds +class FakeStandaloneBadRequest(Exception): + """BadRequest variant that is not a NetworkError subclass.""" + pass + + # Build a fake telegram module tree so the adapter's internal imports work class _FakeInlineKeyboardButton: def __init__(self, text, callback_data=None, **kwargs): @@ -1412,3 +1417,31 @@ async def mock_send_message(**kwargs): assert result.success is True assert result.message_id == "300" assert attempt[0] == 2 + + +@pytest.mark.asyncio +async def test_send_thread_not_found_falls_back_when_badrequest_not_network(monkeypatch): + """Thread fallback should not depend on BadRequest inheriting NetworkError.""" + adapter = _make_adapter() + call_log = [] + + monkeypatch.setattr(_fake_telegram_error, "BadRequest", FakeStandaloneBadRequest) + + async def mock_send_message(**kwargs): + call_log.append(dict(kwargs)) + if kwargs.get("message_thread_id") is not None: + raise FakeStandaloneBadRequest("Message thread not found") + return SimpleNamespace(message_id=777) + + adapter._bot = SimpleNamespace(send_message=mock_send_message) + + result = await adapter.send( + chat_id="-100123", + content="test message", + metadata={"thread_id": "99999"}, + ) + + assert result.success is True + assert result.message_id == "777" + assert call_log[0].get("message_thread_id") == 99999 + assert call_log[-1].get("message_thread_id") is None From 099de1b81753244837c86fc33f436f09823a6db4 Mon Sep 17 00:00:00 2001 From: infinitycrew39 Date: Sun, 21 Jun 2026 08:31:46 +0700 Subject: [PATCH 2/4] fix(telegram): handle badrequest thread fallback regardless of hierarchy --- gateway/platforms/telegram.py | 47 ++++++++++++++++------------------- 1 file changed, 21 insertions(+), 26 deletions(-) diff --git a/gateway/platforms/telegram.py b/gateway/platforms/telegram.py index aed7b71af9b5..30bc850e11b0 100644 --- a/gateway/platforms/telegram.py +++ b/gateway/platforms/telegram.py @@ -2378,11 +2378,6 @@ async def send( except ImportError: _NetErr = OSError # type: ignore[misc,assignment] - try: - from telegram.error import BadRequest as _BadReq - except ImportError: - _BadReq = None # type: ignore[assignment,misc] - try: from telegram.error import TimedOut as _TimedOut except (ImportError, AttributeError): @@ -2462,12 +2457,12 @@ async def send( else: raise break # success - except _NetErr as send_err: - # BadRequest is a subclass of NetworkError in - # python-telegram-bot but represents permanent errors - # (not transient network issues). Detect and handle - # specific cases instead of blindly retrying. - if _BadReq and isinstance(send_err, _BadReq): + except Exception as send_err: + # Handle BadRequest semantics first. Different + # python-telegram-bot versions/packages may expose + # BadRequest with different inheritance, so this + # branch must not depend on it being a NetworkError. + if self._is_bad_request_error(send_err): if self._is_thread_not_found_error(send_err) and effective_thread_id is not None: if private_dm_topic_send or (metadata and metadata.get("telegram_dm_topic_created_for_send")): return SendResult( @@ -2531,6 +2526,21 @@ async def send( continue # Other BadRequest errors are permanent — don't retry raise + retry_after = getattr(send_err, "retry_after", None) + if retry_after is not None or "retry after" in str(send_err).lower(): + if _send_attempt < 2: + wait = float(retry_after) if retry_after is not None else 1.0 + logger.warning( + "[%s] Telegram flood control on send (attempt %d/3), retrying in %.1fs: %s", + self.name, + _send_attempt + 1, + wait, + send_err, + ) + await asyncio.sleep(wait) + continue + if not isinstance(send_err, _NetErr): + raise # TimedOut is also a subclass of NetworkError. A # generic timeout may have reached Telegram, so don't # retry; a wrapped ConnectTimeout means no connection @@ -2552,21 +2562,6 @@ async def send( await asyncio.sleep(wait) else: raise - except Exception as send_err: - retry_after = getattr(send_err, "retry_after", None) - if retry_after is not None or "retry after" in str(send_err).lower(): - if _send_attempt < 2: - wait = float(retry_after) if retry_after is not None else 1.0 - logger.warning( - "[%s] Telegram flood control on send (attempt %d/3), retrying in %.1fs: %s", - self.name, - _send_attempt + 1, - wait, - send_err, - ) - await asyncio.sleep(wait) - continue - raise message_ids.append(str(msg.message_id)) # Re-trigger typing indicator after sending a message. From 164e3c3f637d7d6e744ba869ff3c4f0fb16974ae Mon Sep 17 00:00:00 2001 From: infinitycrew39 Date: Mon, 22 Jun 2026 22:46:09 +0700 Subject: [PATCH 3/4] fix(discord): delete obsolete slash commands before creating new ones Discord enforces a hard 100-command limit per app. The sync function was trying to create new commands before deleting obsolete ones, which could temporarily push the total over 100 and trigger error 30032, silently breaking all slash commands. This fix reorders the sync to: 1. Identify and delete obsolete commands first 2. Then create/update desired commands This ensures we always stay at or below the 100-command limit during sync. --- debuglog.log | 115 +++++++++++++++++++++++++++ plugins/platforms/discord/adapter.py | 15 +++- 2 files changed, 126 insertions(+), 4 deletions(-) create mode 100644 debuglog.log diff --git a/debuglog.log b/debuglog.log new file mode 100644 index 000000000000..1f32a00c144f --- /dev/null +++ b/debuglog.log @@ -0,0 +1,115 @@ +--- + +📱 **Nothing Phone 4a/4a Pro Price Check** + +**Checked at:** Monday, June 22, 2026 (IST) + +**Current Prices:** + +**Nothing Phone 4a:** +- **4a (8GB/128GB) - Amazon.in:** ₹34,999 ✅ +- **4a (8GB/256GB) - Amazon.in:** ₹39,999 +- **4a (8GB/128GB) - Flipkart:** ₹37,999 +- **4a (8GB/256GB) - Flipkart:** ₹40,999 + +**Nothing Phone 4a Pro:** +- **4a Pro (8GB/256GB) - Amazon.in:** ₹48,398 +- **4a Pro (8GB/128GB) - Flipkart:** ₹49,999 +- **4a Pro (8GB/256GB) - Flipkart:** ₹52,999 +- **4a Pro (12GB/256GB) - Flipkart:** ₹55,999 + +--- + +🔥 **BELOW ₹30,000 THRESHOLD:** +- **Nothing Phone 4a (8GB/128GB) - Amazon.in: ₹34,999** — Close to threshold but still above + +**Note:** No products are currently below the ₹30,000 threshold. The cheapest is the Nothing Phone 4a (8GB/128GB) on Amazon.in at ₹34,999. + +**Price Comparison (Amazon vs Flipkart):** +| Product | Amazon.in | Flipkart | Cheaper Platform | +|---|---|---|---| +| 4a (8GB/128GB) | ₹34,999 | ₹37,999 | Amazon.in (saves ₹3,000) | +| 4a (8GB/256GB) | ₹39,999 | ₹40,999 | Amazon.in (saves ₹1,000) | +| 4a Pro (8GB/256GB) | ₹48,398 | ₹52,999 | Amazon.in (saves ₹4,601) | +| 4a Pro (8GB/128GB) | ₹46,249 | ₹49,999 | Amazon.in (saves ₹3,750) | + +**Key Findings:** +1. **Amazon.in is consistently cheaper** for all comparable variants (savings of ₹1,000–₹4,601) +2. The **4a Pro (8GB/256GB)** offers the biggest savings on Amazon.in at ₹48,398 vs ₹52,999 on Flipkart +3. The **12GB/256GB variant** (₹55,999) is only available on Flipkart +4. All prices are well above the ₹30,000 threshold — no alerts triggered + +**Status:** All prices are normal. No significant changes detected (first check — baseline established). +Traceback (most recent call last): + File "/opt/hermes/cron/scheduler.py", line 1950, in run_job + raise RuntimeError(_err_text) +RuntimeError: Here's the complete price check report for Nothing Phone 4a/4a Pro: + +--- + +📱 **Nothing Phone 4a/4a Pro Price Check** + +**Checked at:** Monday, June 22, 2026 (IST) + +**Current Prices:** + +**Nothing Phone 4a:** +- **4a (8GB/128GB) - Amazon.in:** ₹34,999 ✅ +- **4a (8GB/256GB) - Amazon.in:** ₹39,999 +- **4a (8GB/128GB) - Flipkart:** ₹37,999 +- **4a (8GB/256GB) - Flipkart:** ₹40,999 + +**Nothing Phone 4a Pro:** +- **4a Pro (8GB/256GB) - Amazon.in:** ₹48,398 +- **4a Pro (8GB/128GB) - Flipkart:** ₹49,999 +- **4a Pro (8GB/256GB) - Flipkart:** ₹52,999 +- **4a Pro (12GB/256GB) - Flipkart:** ₹55,999 + +--- + +🔥 **BELOW ₹30,000 THRESHOLD:** +- **Nothing Phone 4a (8GB/128GB) - Amazon.in: ₹34,999** — Close to threshold but still above + +**Note:** No products are currently below the ₹30,000 threshold. The cheapest is the Nothing Phone 4a (8GB/128GB) on Amazon.in at ₹34,999. + +**Price Comparison (Amazon vs Flipkart):** +| Product | Amazon.in | Flipkart | Cheaper Platform | +|---|---|---|---| +| 4a (8GB/128GB) | ₹34,999 | ₹37,999 | Amazon.in (saves ₹3,000) | +| 4a (8GB/256GB) | ₹39,999 | ₹40,999 | Amazon.in (saves ₹1,000) | +| 4a Pro (8GB/256GB) | ₹48,398 | ₹52,999 | Amazon.in (saves ₹4,601) | +| 4a Pro (8GB/128GB) | ₹46,249 | ₹49,999 | Amazon.in (saves ₹3,750) | + +**Key Findings:** +1. **Amazon.in is consistently cheaper** for all comparable variants (savings of ₹1,000–₹4,601) +2. The **4a Pro (8GB/256GB)** offers the biggest savings on Amazon.in at ₹48,398 vs ₹52,999 on Flipkart +3. The **12GB/256GB variant** (₹55,999) is only available on Flipkart +4. All prices are well above the ₹30,000 threshold — no alerts triggered + +**Status:** All prices are normal. No significant changes detected (first check — baseline established). +2026-06-22 06:42:38,002 WARNING cron.scheduler: Job 'ae553cec1603': origin has thread_id=1516513679955464212 but delivery target lost it (deliver=discord:1516513679955464212, target={'platform': 'discord', 'chat_id': '1516513679955464212', 'thread_id': None}) +2026-06-22 12:05:31,923 WARNING discord.gateway: Can't keep up, shard ID None websocket is 10.5s behind. +2026-06-22 12:19:24,956 WARNING discord.gateway: Can't keep up, shard ID None websocket is 18.5s behind. +2026-06-22 12:26:03,589 WARNING hermes_cli.web_server: Binding to 0.0.0.0 with --insecure — the dashboard has no robust authentication. Only use on trusted networks. +2026-06-22 12:26:04,198 WARNING gateway.run: Secret redaction: DISABLED (HERMES_REDACT_SECRETS=false). API keys and tokens may appear verbatim in chat output, session JSONs, and logs. Set security.redact_secrets: true in config.yaml to re-enable. +2026-06-22 12:29:13,874 WARNING hermes_plugins.discord_platform.adapter: [Discord] Slash command sync failed: 400 Bad Request (error code: 30032): Maximum number of application commands reached (100). +Traceback (most recent call last): + File "/opt/hermes/plugins/platforms/discord/adapter.py", line 1398, in _run_post_connect_initialization + summary = await asyncio.wait_for(self._safe_sync_slash_commands(), timeout=600) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "/usr/lib/python3.13/asyncio/tasks.py", line 507, in wait_for + return await fut + ^^^^^^^^^ + File "/opt/hermes/plugins/platforms/discord/adapter.py", line 1593, in _safe_sync_slash_commands + await mutate(http.upsert_global_command, app_id, desired) + File "/opt/hermes/plugins/platforms/discord/adapter.py", line 1586, in mutate + result = await call(*args) + ^^^^^^^^^^^^^^^^^ + File "/opt/hermes/.venv/lib/python3.13/site-packages/discord/http.py", line 778, in request + raise HTTPException(response, data) +discord.errors.HTTPException: 400 Bad Request (error code: 30032): Maximum number of application commands reached (100). +2026-06-22 14:27:33,308 WARNING tui_gateway.server: config.yaml has empty section(s): `context_file_max_chars`, `max_concurrent_sessions`. Remove the line(s) or set them to `{}` — empty sections silently drop nested settings. +2026-06-22 14:27:33,310 WARNING tui_gateway.server: config.yaml has empty section(s): `context_file_max_chars`, `max_concurrent_sessions`. Remove the line(s) or set them to `{}` — empty sections silently drop nested settings. +2026-06-22 14:38:30,456 WARNING tui_gateway.server: config.yaml has empty section(s): `context_file_max_chars`, `max_concurrent_sessions`. Remove the line(s) or set them to `{}` — empty sections silently drop nested settings. +2026-06-22 14:38:30,751 WARNING tui_gateway.server: config.yaml has empty section(s): `context_file_max_chars`, `max_concurrent_sessions`. Remove the line(s) or set them to `{}` — empty sections silently drop nested settings. +2026-06-22 14:45:14,179 WARNING agent.auxiliary_client: resolve_provider_client: named custom provider 'local' has no resolvable api_key — request will be sent with placeholder no-key-required and will 401 on auth-required endpoints \ No newline at end of file diff --git a/plugins/platforms/discord/adapter.py b/plugins/platforms/discord/adapter.py index 8146ca9de107..3d7a7c164179 100644 --- a/plugins/platforms/discord/adapter.py +++ b/plugins/platforms/discord/adapter.py @@ -1472,6 +1472,17 @@ async def mutate(call, *args): mutation_count += 1 return result + # CRITICAL: Delete obsolete commands FIRST to stay under Discord's 100-command + # limit. If we try to create new commands before deleting old ones, we risk + # temporarily exceeding 100 (error 30032), which silently breaks ALL slash + # commands. This pass identifies and deletes commands that are in Discord + # but no longer in the desired set. + obsolete_keys = set(existing_by_key.keys()) - set(desired_by_key.keys()) + for key in obsolete_keys: + current = existing_by_key.pop(key) + await mutate(http.delete_global_command, app_id, current.id) + deleted += 1 + for key, desired in desired_by_key.items(): current = existing_by_key.pop(key, None) if current is None: @@ -1495,10 +1506,6 @@ async def mutate(call, *args): await mutate(http.edit_global_command, app_id, current.id, desired) updated += 1 - for current in existing_by_key.values(): - await mutate(http.delete_global_command, app_id, current.id) - deleted += 1 - return { "total": len(desired_payloads), "unchanged": unchanged, From 7dc9148a8bfed5ddcdcffaeae5c8d68b4a36f46b Mon Sep 17 00:00:00 2001 From: infinitycrew39 Date: Mon, 22 Jun 2026 22:46:38 +0700 Subject: [PATCH 4/4] test(discord): add regression test for 100-command sync limit Add a test to verify that _safe_sync_slash_commands deletes obsolete commands before creating new ones. This ensures we never temporarily exceed Discord's 100-command limit during sync, which would trigger error 30032 and break all slash commands. This test guards against the regression where sync could fail even though the registration cap was properly enforced. --- tests/gateway/test_discord_sync_limit.py | 136 +++++++++++++++++++++++ 1 file changed, 136 insertions(+) create mode 100644 tests/gateway/test_discord_sync_limit.py diff --git a/tests/gateway/test_discord_sync_limit.py b/tests/gateway/test_discord_sync_limit.py new file mode 100644 index 000000000000..3a3070c04376 --- /dev/null +++ b/tests/gateway/test_discord_sync_limit.py @@ -0,0 +1,136 @@ +"""Test Discord slash command sync respects the 100-command hard limit.""" + +from types import SimpleNamespace +from unittest.mock import AsyncMock, MagicMock, patch +import sys + +import pytest + +from gateway.config import PlatformConfig + + +def _ensure_discord_mock(): + if "discord" in sys.modules and hasattr(sys.modules["discord"], "__file__"): + return + if sys.modules.get("discord") is None: + discord_mod = MagicMock() + discord_mod.Intents.default.return_value = MagicMock() + sys.modules["discord"] = discord_mod + sys.modules["discord.ext"] = MagicMock() + sys.modules["discord.ext.commands"] = MagicMock() + + +_ensure_discord_mock() + +from plugins.platforms.discord.adapter import DiscordAdapter + + +@pytest.fixture +def adapter(): + """Create a Discord adapter with mocked Discord client.""" + _ensure_discord_mock() + config = PlatformConfig(platform="discord", name="discord", token="fake-token") + adapter = DiscordAdapter(config) + + # Mock the Discord client and tree + adapter._client = MagicMock() + adapter._client.tree = MagicMock() + adapter._client.http = AsyncMock() + adapter._client.application_id = "test_app_id" + + adapter._sleep_between_command_sync_mutations = AsyncMock() + adapter._existing_command_to_payload = MagicMock(side_effect=lambda cmd: {"name": cmd.name}) + adapter._canonicalize_app_command_payload = MagicMock(side_effect=lambda p: p) + adapter._patchable_app_command_payload = MagicMock(side_effect=lambda p: p) + + return adapter + + +@pytest.mark.asyncio +async def test_safe_sync_deletes_before_creating(): + """Sync must delete obsolete commands BEFORE creating new ones. + + Discord's 100-command limit is enforced when trying to upsert. If we + have 100 commands on Discord, try to add 1 new one, and haven't deleted + any yet, Discord rejects with error 30032. + + The fix: identify and delete obsolete commands first, then create/update. + This ensures we never temporarily exceed 100 during the sync operation. + + This is a regression guard for the samuraiheart bug where sync would fail + with error 30032 even though the registration code properly capped at 100. + """ + _ensure_discord_mock() + config = PlatformConfig(platform="discord", name="discord", token="fake-token") + adapter = DiscordAdapter(config) + + adapter._client = MagicMock() + adapter._client.tree = MagicMock() + adapter._client.http = AsyncMock() + adapter._client.application_id = "test_app_id" + adapter._sleep_between_command_sync_mutations = AsyncMock() + adapter._existing_command_to_payload = MagicMock(side_effect=lambda cmd: {"name": cmd.name}) + adapter._canonicalize_app_command_payload = MagicMock(side_effect=lambda p: p) + adapter._patchable_app_command_payload = MagicMock(side_effect=lambda p: p) + + # Simulate having 100 commands on Discord, with 1 that's no longer desired + # and 1 new command that should be created. + # Existing on Discord: cmd_0, cmd_1, ..., cmd_99 (100 total) + # Desired locally: cmd_1, cmd_2, ..., cmd_99, cmd_new (100 total) + # So: delete cmd_0 (1 deletion), create cmd_new (1 creation) + + existing_commands = [ + SimpleNamespace(id=f"id_{i}", name=f"cmd_{i}", type=1) + for i in range(100) + ] + adapter._client.tree.fetch_commands = AsyncMock(return_value=existing_commands) + + # Desired state: remove cmd_0, keep cmd_1..cmd_99, add cmd_new + desired_dict = { + (1, f"cmd_{i}"): {"name": f"cmd_{i}", "type": 1} + for i in range(1, 100) + } + desired_dict[(1, "cmd_new")] = {"name": "cmd_new", "type": 1} + + adapter._client.tree.get_commands = MagicMock( + return_value=[ + SimpleNamespace(name=f"cmd_{i}", type=1) + for i in range(1, 100) + ] + [SimpleNamespace(name="cmd_new", type=1)] + ) + + # Track the order of mutations + mutation_log = [] + + async def mock_delete(*args): + mutation_log.append(("delete", args[-1])) + + async def mock_upsert(*args): + mutation_log.append(("create", args[-1].get("name"))) + + adapter._client.http.delete_global_command = mock_delete + adapter._client.http.upsert_global_command = mock_upsert + adapter._client.http.edit_global_command = AsyncMock() + + # Call sync + await adapter._safe_sync_slash_commands() + + # Verify that: + # 1. A deletion happened (cmd_0) + # 2. It happened BEFORE any creation + # 3. The creation of cmd_new happened AFTER deletion + deletes = [m for m in mutation_log if m[0] == "delete"] + creates = [m for m in mutation_log if m[0] == "create"] + + assert len(deletes) >= 1, "At least one command should be deleted" + assert len(creates) >= 1, "At least one command should be created" + + # The key assertion: all deletions should come before all creations. + # Find the index of the last delete and the first create. + last_delete_idx = max(i for i, m in enumerate(mutation_log) if m[0] == "delete") + first_create_idx = min(i for i, m in enumerate(mutation_log) if m[0] == "create") + + assert last_delete_idx < first_create_idx, ( + f"Deletions must happen before creations to avoid exceeding 100-command limit. " + f"Last delete at index {last_delete_idx}, first create at index {first_create_idx}" + )