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/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. 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, 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}" + ) 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