fix(gateway): read adapter token from config for fingerprint check
_adapter_credential_fingerprint only looked at adapter.token directly, but Discord (and similar) adapters store the bot token on their config sub-object, not on self. Every Discord adapter in a multiplexed gateway therefore returned None, the same-token conflict check was silently skipped, and N adapters all polled the same bot token — producing a per-message race where whichever adapter won the GIL answered the user. Adds a config-token fallback (token, then bot_token) so the check actually fires for config-backed adapters. Direct adapter.token still takes precedence when both exist. Tests cover: config-backed token produces a fingerprint, distinct tokens produce distinct fingerprints, direct token wins over config, config without token attributes returns None. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
parent
a55523fd6d
commit
a1d6654264
2 changed files with 66 additions and 0 deletions
|
|
@ -8785,6 +8785,20 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
|
|||
if isinstance(val, str) and val.strip():
|
||||
token = val.strip()
|
||||
break
|
||||
# Many adapters (e.g. Discord) store the token on their `config`
|
||||
# sub-object rather than directly on the adapter. Without this lookup
|
||||
# those adapters all return None here, the same-token conflict check
|
||||
# is silently skipped, and every profile's adapter for that platform
|
||||
# starts polling the same bot token — producing a per-message race
|
||||
# for which adapter answers. See test_reads_config_token.
|
||||
if not token:
|
||||
cfg = getattr(adapter, "config", None)
|
||||
if cfg is not None:
|
||||
for attr in ("token", "bot_token"):
|
||||
val = getattr(cfg, attr, None)
|
||||
if isinstance(val, str) and val.strip():
|
||||
token = val.strip()
|
||||
break
|
||||
if not token:
|
||||
config = getattr(adapter, "config", None)
|
||||
val = getattr(config, "token", None)
|
||||
|
|
|
|||
|
|
@ -45,6 +45,58 @@ class TestCredentialFingerprint:
|
|||
assert "config-token" not in fp
|
||||
|
||||
|
||||
def test_reads_config_token(self):
|
||||
"""Adapters like Discord store token on `config`, not on self.
|
||||
|
||||
Without the config-token fallback, every Discord adapter in a
|
||||
multiplexed gateway returns None here and the same-token conflict
|
||||
check is silently skipped — N adapters start polling the same bot
|
||||
token and race on every inbound message.
|
||||
"""
|
||||
class _Config:
|
||||
token = "discord-bot-token"
|
||||
class _ConfigBackedAdapter:
|
||||
config = _Config()
|
||||
fp = GatewayRunner._adapter_credential_fingerprint(_ConfigBackedAdapter())
|
||||
assert fp is not None
|
||||
assert "discord-bot-token" not in fp
|
||||
assert len(fp) == 16
|
||||
|
||||
def test_distinct_config_tokens_distinct_fp(self):
|
||||
class _CfgA:
|
||||
token = "tok-A"
|
||||
class _CfgB:
|
||||
token = "tok-B"
|
||||
class _A:
|
||||
config = _CfgA()
|
||||
class _B:
|
||||
config = _CfgB()
|
||||
a = GatewayRunner._adapter_credential_fingerprint(_A())
|
||||
b = GatewayRunner._adapter_credential_fingerprint(_B())
|
||||
assert a is not None and b is not None
|
||||
assert a != b
|
||||
|
||||
def test_direct_token_takes_precedence_over_config(self):
|
||||
"""If both `adapter.token` and `adapter.config.token` exist, direct wins."""
|
||||
class _Cfg:
|
||||
token = "from-config"
|
||||
class _Both:
|
||||
token = "from-direct"
|
||||
config = _Cfg()
|
||||
fp = GatewayRunner._adapter_credential_fingerprint(_Both())
|
||||
import hashlib
|
||||
expected = hashlib.sha256(b"hermes-mux:from-direct").hexdigest()[:16]
|
||||
assert fp == expected
|
||||
|
||||
def test_config_without_token_returns_none(self):
|
||||
"""config present but no token attribute → None (no false positive)."""
|
||||
class _Cfg:
|
||||
pass
|
||||
class _Adapter:
|
||||
config = _Cfg()
|
||||
assert GatewayRunner._adapter_credential_fingerprint(_Adapter()) is None
|
||||
|
||||
|
||||
class TestProfileMessageHandler:
|
||||
@pytest.mark.asyncio
|
||||
async def test_stamps_profile_on_unstamped_source(self):
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue