Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
115 changes: 115 additions & 0 deletions debuglog.log
Original file line number Diff line number Diff line change
@@ -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
47 changes: 21 additions & 26 deletions gateway/platforms/telegram.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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
Expand All @@ -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.
Expand Down
15 changes: 11 additions & 4 deletions plugins/platforms/discord/adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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,
Expand Down
136 changes: 136 additions & 0 deletions tests/gateway/test_discord_sync_limit.py
Original file line number Diff line number Diff line change
@@ -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}"
)
Loading