fix(wechat): deny all users when allowed_users has no valid ID (#6376)

* fix(wechat): deny all users when allowed_users has no valid ID

A single iLink user ID was split into characters, and a list with no
usable ID collapsed to an empty set that the inbound gate read as
allow-all.

* fix(wechat): fail closed on numeric and mapping allowed_users

Integer-valued floats now match the sender's integer text. Fractions,
non-finite numbers, and mappings no longer admit anyone, and a denied
sender is dropped before inbound media is downloaded.

* docs(channels): keep WeChat guidance within size budget

* fix(wechat): warn on scalar allowlist separators

* refactor(channels): share fail-closed allowlist parsing

---------

Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
This commit is contained in:
Shxiao 2026-10-06 22:17:11 +09:00 committed by GitHub
parent eeb2c4dbcc
commit a97772d016
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 346 additions and 32 deletions

View File

@ -868,7 +868,7 @@ channels:
bot_token: $WECHAT_BOT_TOKEN
ilink_bot_id: $WECHAT_ILINK_BOT_ID
qrcode_login_enabled: true # optional: allow first-time QR bootstrap when bot_token is absent
allowed_users: [] # empty = allow all
allowed_users: [] # iLink user IDs; empty allows all; one ID is one entry
polling_timeout: 35 # timing values must be positive finite seconds
polling_retry_delay: 5
qrcode_poll_interval: 2
@ -969,6 +969,7 @@ DINGTALK_CLIENT_SECRET=your_client_secret
4. After the QR flow succeeds, DeerFlow persists the acquired token under `state_dir` for later restarts.
5. For Docker Compose deployments, keep `state_dir` on a persistent volume so the `get_updates_buf` cursor and saved auth state survive restarts.
6. Outbound images/files enforce `max_outbound_image_bytes` / `max_outbound_file_bytes` (20 MiB / 50 MiB defaults) while reading, including files that grow after resolution. Oversize reads are rejected before encryption/upload instead of sending a truncated prefix. Non-positive limits disable the corresponding cap.
7. `allowed_users` takes iLink user IDs. Unset, `null`, `[]`, or a blank string allows everyone. A single ID is one entry, not a sequence of characters, and an unquoted integer-valued number is stored as that integer's text. A scalar string containing commas or interior whitespace logs a warning but remains one literal ID; use a YAML list for multiple IDs. Invalid entries are ignored with a warning; any other configured value that yields no valid ID denies every user and logs an error. `/connect` is still accepted before that check, and a denied sender is dropped before inbound media is downloaded.
**WeCom Setup**

View File

@ -60,8 +60,9 @@ The cached value is reused for both the blocking (`runs.wait`) and streaming (`_
**User-owned channel connections** (`config.yaml` -> `channel_connections`):
- Disabled by default. It is a user-binding layer on top of the existing `channels.*` runtime config, not a replacement for provider bot credentials.
- No public IP, OAuth callback URL, or provider webhook route is required by the current implementation.
- Telegram uses a deep-link `/start <code>` flow over the existing long-polling worker. Slack, Discord, Feishu/Lark, DingTalk, WeChat, and WeCom use `/connect <code>` over their existing outbound channel workers.
- WeChat timing settings (`polling_timeout`, `polling_retry_delay`, `qrcode_poll_interval`, `qrcode_poll_timeout`) accept only positive finite seconds; invalid values fall back to their defaults so polling cannot enter a hot loop or sleep forever.
- Telegram binds via deep-link `/start <code>` over long polling; Slack, Discord, Feishu/Lark, DingTalk, WeChat, and WeCom use `/connect <code>` through their outbound workers.
- WeChat `polling_timeout`, `polling_retry_delay`, `qrcode_poll_interval`, and `qrcode_poll_timeout` require positive finite seconds; invalid values use defaults to prevent hot loops or endless sleeps.
- `allowed_users.py` shares WeChat/Telegram fail-closed parsing with channel-specific ID coercion: unset/null/empty collections/blank string allow all; invalid entries warn, all-invalid values deny all and log an error. WeChat stores integer-valued numbers as integer text, warns on scalar commas/interior whitespace without splitting, and rejects denied senders before media downloads.
- WeCom serializes `start()` and `stop()` for each channel instance. The SDK `connect()` task covers connection setup only; after the handshake, the SDK owns a separate receive task. Shutdown cancels an in-progress connection attempt and awaits the SDK's actual asynchronous receive-task/socket cleanup before releasing lifecycle state or allowing a restart. Cancellation of `stop()` still propagates, but only after owned cleanup finishes and lifecycle references are cleared; real connection failures remain reported by `_on_ws_task_done`.
- WeCom outbound content is capped at the protocol's 20480 UTF-8 bytes: stream replies clip on a character boundary with a truncation marker (one stream carries the whole reply and cannot split mid-way), while proactive pushes split into at most 10 sequential markdown messages per push, with the remaining tail clipped and marker-terminated. Both paths measure bytes, not characters. A per-chat send lock serializes each split batch end to end, because manager workers run concurrently and two long pushes to the same chat would otherwise interleave chunks; locks are reference-counted and reclaimed once no sender holds or waits on them, so the registry does not grow for the life of the Gateway.
- Frontend APIs: `GET /api/channels/providers`, `GET /api/channels/connections`, `POST /api/channels/{provider}/connect`, and `DELETE /api/channels/connections/{connection_id}`.
@ -70,7 +71,7 @@ The cached value is reused for both the blocking (`runs.wait`) and streaming (`_
- Provider-level `connection_status` reflects the user's newest connection row. With no binding it is `not_connected`, except in auth-disabled local mode where a configured running channel reports `connected` because all channel messages already route to the default user.
- Slack replies use the configured operator bot token from `channels.slack` unless per-connection credentials are present; unreadable or corrupt stored credentials are treated as unavailable.
- Telegram, Slack, Discord, Feishu/Lark, DingTalk, WeChat, and WeCom workers resolve incoming platform identities to connection records before reaching `ChannelManager`.
- **Connect-code ordering vs `allowed_users`**: inbound workers consume a valid `/connect <code>` (or Telegram `/start <code>`) **before** applying the `allowed_users` filter, so a newly allowlisted-but-unbound user can bootstrap their first bind via the browser flow. Consequence: `allowed_users` is **not** a bind-time defense — any sender who possesses a valid code can consume it (not only allowlisted users). The bind security model rests on the code's confidentiality: `secrets.token_urlsafe(16)`, 600 s TTL, one-time `consume_oauth_state`, and codes surfaced only in the initiating browser (never echoed to chat). `allowed_users` still gates ordinary (non-bind) messages.
- **Connect-code ordering**: `/connect <code>` (Telegram `/start <code>`) precedes `allowed_users`, so even non-allowlisted senders can bind with a valid code. The allowlist gates ordinary messages. Codes rely on secrecy: `secrets.token_urlsafe(16)`, 600 s TTL, one-time `consume_oauth_state`, shown only in the initiating browser, never echoed to chat.
- **Single-active-owner transfer semantics**: an external identity is keyed by `(provider, external_account_id, workspace_id)`. The latest successful bind wins — `upsert_connection` revokes other owners' active rows for the same identity (ownership transfer). This invariant is enforced at the DB layer by the partial unique index `uq_channel_connection_active_identity` (`WHERE status != 'revoked'`), so concurrent connects from different owners cannot both end `connected`; the losing writer retries against the now-visible state. `find_connection_by_external_identity` therefore resolves deterministically.
- See `backend/docs/IM_CHANNEL_CONNECTIONS.md` for provider setup, operational notes, and the architecture diagrams (connect-code flow, single-active-owner transfer, sync vs streaming dispatch, owner-scoped file storage pipeline).

View File

@ -0,0 +1,41 @@
"""Shared fail-closed parsing for channel user allowlists."""
from __future__ import annotations
import logging
from collections.abc import Callable, Hashable
from typing import Any
def parse_allowed_users[UserId: Hashable](
allowed_users: Any,
*,
parse_user_id: Callable[[Any], UserId | None],
logger: logging.Logger,
channel_name: str,
expected_id: str,
valid_id_name: str = "user ID",
) -> frozenset[UserId] | None:
"""Return ``None`` for an unset/empty allowlist, or its valid user IDs.
Scalars are single entries; mappings must not authorize their keys. The
channel's coercer validates each entry. Invalid entries warn and are dropped,
but a configured value with no valid entry returns an empty set and logs an
error, so callers deny everyone instead of silently removing the restriction.
"""
if allowed_users is None or (isinstance(allowed_users, str) and not allowed_users.strip()):
return None
entries = list(allowed_users) if isinstance(allowed_users, (list, tuple, set, frozenset)) else [allowed_users]
if not entries:
return None
user_ids: set[UserId] = set()
for entry in entries:
user_id = parse_user_id(entry)
if user_id is None:
logger.warning("[%s] Ignoring allowed_users entry %r: expected %s; list several IDs as a YAML list", channel_name, entry, expected_id)
else:
user_ids.add(user_id)
if not user_ids:
logger.error("[%s] allowed_users has no valid %s; denying every user until it is fixed", channel_name, valid_id_name)
return frozenset(user_ids)

View File

@ -10,6 +10,7 @@ import time
from collections.abc import Coroutine
from typing import Any
from app.channels.allowed_users import parse_allowed_users
from app.channels.base import Channel, ChannelStopTimeout
from app.channels.connection_identity import attach_connection_identity
from app.channels.message_bus import (
@ -113,28 +114,15 @@ def _parse_telegram_user_id(entry: Any) -> int | None:
def _parse_allowed_users(allowed_users: Any) -> frozenset[int] | None:
"""Parse ``channels.telegram.allowed_users``; ``None`` means no allowlist.
A single ID is shorthand for a one-entry list. Entries that are not numeric
user IDs (``@usernames``, floats, booleans) are dropped with a warning. If
none remain, the result is an empty set that denies everyone: the operator
asked for a restriction, so an unreadable one must not open the bot to all.
"""
if allowed_users is None or (isinstance(allowed_users, str) and not allowed_users.strip()):
return None
entries = list(allowed_users) if isinstance(allowed_users, list | tuple | set) else [allowed_users]
if not entries:
return None
user_ids: set[int] = set()
for entry in entries:
user_id = _parse_telegram_user_id(entry)
if user_id is None:
logger.warning("[Telegram] Ignoring allowed_users entry %r: expected a positive numeric Telegram user ID (not an @username); list several IDs as a YAML list", entry)
else:
user_ids.add(user_id)
if not user_ids:
logger.error("[Telegram] allowed_users has no valid numeric user ID; denying every user until it is fixed")
return frozenset(user_ids)
"""Parse positive numeric Telegram IDs with shared fail-closed semantics."""
return parse_allowed_users(
allowed_users,
parse_user_id=_parse_telegram_user_id,
logger=logger,
channel_name="Telegram",
expected_id="a positive numeric Telegram user ID (not an @username)",
valid_id_name="numeric user ID",
)
class TelegramChannel(Channel):

View File

@ -23,6 +23,7 @@ import httpx
from cryptography.hazmat.primitives import padding
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
from app.channels.allowed_users import parse_allowed_users
from app.channels.base import Channel
from app.channels.commands import is_known_channel_command
from app.channels.connection_identity import attach_connection_identity
@ -168,6 +169,41 @@ def _detect_image_extension_and_mime(content: bytes) -> tuple[str, str] | None:
return None
def _parse_wechat_user_id(entry: Any) -> str | None:
"""A valid iLink user ID from a string or numeric scalar, else ``None``.
A finite integer-valued float uses its integer text, so YAML ``12345.0``
matches the sender ``12345``. Fractional and non-finite floats are not IDs.
A numeric string is kept as written: ``"12345.0"`` does not become ``12345``.
"""
if isinstance(entry, bool):
# bool is an int subclass: reject booleans before the numeric branch.
return None
if isinstance(entry, float):
if not math.isfinite(entry) or not entry.is_integer():
return None
entry = int(entry)
if isinstance(entry, int):
return str(entry)
if isinstance(entry, str):
text = entry.strip()
return text or None
return None
def _parse_allowed_users(allowed_users: Any) -> frozenset[str] | None:
"""Parse iLink IDs without splitting a scalar string into multiple IDs."""
if isinstance(allowed_users, str) and ("," in allowed_users or any(char.isspace() for char in allowed_users.strip())):
logger.warning("[WeChat] allowed_users is a scalar string containing separators; treating it as one literal user ID. Use a YAML list for multiple IDs")
return parse_allowed_users(
allowed_users,
parse_user_id=_parse_wechat_user_id,
logger=logger,
channel_name="WeChat",
expected_id="a valid iLink user ID",
)
class WechatChannel(Channel):
"""WeChat iLink bot channel using long-polling.
@ -175,7 +211,9 @@ class WechatChannel(Channel):
- ``bot_token``: iLink bot token used for authenticated API calls.
- ``qrcode_login_enabled``: (optional) Allow first-time QR bootstrap when ``bot_token`` is missing.
- ``base_url``: (optional) iLink API base URL.
- ``allowed_users``: (optional) List of allowed iLink user IDs. Empty = allow all.
- ``allowed_users``: (optional) List of allowed iLink user IDs, or a single
ID. Empty = allow all; a configured value with no valid ID denies everyone.
Integer-valued numbers are stored as integer text. A mapping is not a list of IDs.
- ``allowed_media_hosts``: (optional) Extra host suffixes inbound media URLs may
be downloaded from, in addition to the platform CDN defaults. Default: ``qq.com``.
- ``polling_timeout``: (optional) Long-poll timeout in seconds. Default: 35.
@ -285,7 +323,7 @@ class WechatChannel(Channel):
self._max_outbound_file_bytes = self._coerce_int(config.get("max_outbound_file_bytes"), self.DEFAULT_MAX_OUTBOUND_FILE_BYTES)
self._allowed_file_extensions = self._coerce_str_set(config.get("allowed_file_extensions"), self.DEFAULT_ALLOWED_FILE_EXTENSIONS)
self._allowed_media_hosts = self._coerce_host_suffixes(config.get("allowed_media_hosts"))
self._allowed_users: set[str] = {str(uid).strip() for uid in config.get("allowed_users", []) if str(uid).strip()}
self._allowed_users: frozenset[str] | None = _parse_allowed_users(config.get("allowed_users"))
self._bot_token = str(config.get("bot_token") or "").strip()
self._ilink_bot_id = str(config.get("ilink_bot_id") or "").strip() or None
self._auth_state: dict[str, Any] = {}
@ -922,7 +960,7 @@ class WechatChannel(Channel):
return self._context_tokens_by_chat.get(msg.chat_id)
def _check_user(self, user_id: str) -> bool:
if not self._allowed_users:
if self._allowed_users is None:
return True
return user_id in self._allowed_users

View File

@ -9421,7 +9421,10 @@ class TestTelegramAllowedUsers:
return TelegramChannel(bus=MessageBus(), config={"bot_token": "test-token", **config_extra})
@pytest.mark.parametrize("config_extra", [{}, {"allowed_users": None}, {"allowed_users": []}, {"allowed_users": " "}])
@pytest.mark.parametrize(
"config_extra",
[{}, {"allowed_users": None}, {"allowed_users": []}, {"allowed_users": ()}, {"allowed_users": set()}, {"allowed_users": frozenset()}, {"allowed_users": " "}],
)
def test_unset_or_empty_allowlist_allows_everyone_without_warning(self, config_extra, caplog):
with caplog.at_level(logging.WARNING, logger="app.channels.telegram"):
ch = self._channel(config_extra)
@ -9431,7 +9434,7 @@ class TestTelegramAllowedUsers:
@pytest.mark.parametrize(
"allowed_users",
[[123456, 7], ["123456", " 7 "], (123456, 7), {123456, 7}],
[[123456, 7], ["123456", " 7 "], (123456, 7), {123456, 7}, frozenset({123456, 7})],
)
def test_numeric_ids_are_allowed_and_others_denied(self, allowed_users):
ch = self._channel({"allowed_users": allowed_users})
@ -9459,6 +9462,15 @@ class TestTelegramAllowedUsers:
# 0 and -5 are numeric, so the hint has to say what they are missing.
assert "positive numeric" in caplog.text
def test_frozen_allowlist_drops_invalid_entries_without_blocking_valid_ids(self, caplog):
with caplog.at_level(logging.WARNING, logger="app.channels.telegram"):
ch = self._channel({"allowed_users": frozenset({123456, "@alice"})})
assert ch._check_user(123456)
assert not ch._check_user(42)
assert "'@alice'" in caplog.text
assert not any(record.levelno == logging.ERROR for record in caplog.records)
@pytest.mark.parametrize(
"allowed_users",
# "123456,789" is what a ``$ENV`` reference to a comma-separated value resolves to.

View File

@ -395,6 +395,239 @@ def test_allowed_users_filter_blocks_non_whitelisted_sender():
_run(go())
class TestWechatAllowedUsers:
"""A restriction the operator configured must never silently open the bot."""
@staticmethod
def _channel(config_extra: dict) -> Any:
from app.channels.wechat import WechatChannel
return WechatChannel(bus=MessageBus(), config={"bot_token": "test-token", **config_extra})
@pytest.mark.parametrize(
"config_extra",
[{}, {"allowed_users": None}, {"allowed_users": []}, {"allowed_users": ()}, {"allowed_users": set()}, {"allowed_users": frozenset()}, {"allowed_users": " "}],
)
def test_unset_or_empty_allowlist_allows_everyone_without_warning(self, config_extra, caplog):
with caplog.at_level(logging.WARNING, logger="app.channels.wechat"):
channel = self._channel(config_extra)
assert channel._check_user("wxid-alice")
assert channel._check_user("wxid-bob")
assert caplog.records == []
@pytest.mark.parametrize("collection", [list, tuple, set, frozenset])
def test_listed_ids_are_allowed_and_others_denied(self, collection):
channel = self._channel({"allowed_users": collection(["wxid-alice", "wxid-bob"])})
assert channel._check_user("wxid-alice")
assert channel._check_user("wxid-bob")
assert not channel._check_user("wxid-stranger")
@pytest.mark.parametrize("allowed_users", ["wxid-alice", 12345], ids=["str", "int"])
def test_scalar_user_id_is_one_entry_not_its_characters(self, allowed_users):
channel = self._channel({"allowed_users": allowed_users})
expected = str(allowed_users)
assert channel._check_user(expected)
assert not channel._check_user(expected[0])
@pytest.mark.parametrize("bad_entry", ["", " ", None, True, 1.5, float("nan"), {"wxid-bob": True}])
def test_unparseable_entry_is_dropped_with_warning(self, bad_entry, caplog):
with caplog.at_level(logging.WARNING, logger="app.channels.wechat"):
channel = self._channel({"allowed_users": ["wxid-alice", bad_entry]})
assert channel._check_user("wxid-alice")
assert not channel._check_user("wxid-stranger")
assert repr(bad_entry) in caplog.text
@pytest.mark.parametrize(
"allowed_users",
[[""], [None], [True], [" "], 1.5, True, {"wxid-alice": True}, {}, float("nan"), float("inf"), float("-inf")],
)
def test_allowlist_without_a_parseable_entry_denies_everyone(self, allowed_users, caplog):
with caplog.at_level(logging.ERROR, logger="app.channels.wechat"):
channel = self._channel({"allowed_users": allowed_users})
assert not channel._check_user("wxid-alice")
assert not channel._check_user("wxid-bob")
assert any(record.levelno == logging.ERROR for record in caplog.records)
def test_scalar_allowlist_admits_the_id_and_not_a_character(self):
from app.channels.wechat import WechatChannel
async def go():
bus = MessageBus()
published = []
async def capture(msg):
published.append(msg)
bus.publish_inbound = capture # type: ignore[method-assign]
channel = WechatChannel(bus=bus, config={"bot_token": "test-token", "allowed_users": "wxid-alice"})
await channel._handle_update(
{
"message_type": 1,
"from_user_id": "w",
"context_token": "ctx-char",
"item_list": [{"type": 1, "text_item": {"text": "hello"}}],
}
)
await channel._handle_update(
{
"message_type": 1,
"from_user_id": "wxid-alice",
"context_token": "ctx-user",
"item_list": [{"type": 1, "text_item": {"text": "hello"}}],
}
)
assert [msg.user_id for msg in published] == ["wxid-alice"]
_run(go())
def test_padded_id_matches_the_stripped_value(self):
channel = self._channel({"allowed_users": [" wxid-alice "]})
assert channel._check_user("wxid-alice")
assert not channel._check_user("wxid-stranger")
def test_integer_valued_float_is_integer_text_and_numeric_strings_stay_literal(self):
number = self._channel({"allowed_users": 12345.0})
assert number._check_user("12345")
assert not number._check_user("12345.0")
assert not number._check_user("1")
literal = self._channel({"allowed_users": "12345.0"})
assert literal._check_user("12345.0")
assert not literal._check_user("12345")
nan_text = self._channel({"allowed_users": "nan"})
assert nan_text._check_user("nan")
def test_set_of_ids_allows_members_and_mapping_does_not_allow_its_keys(self):
listed = self._channel({"allowed_users": {"wxid-alice"}})
assert listed._check_user("wxid-alice")
assert not listed._check_user("wxid-stranger")
mapping = self._channel({"allowed_users": {"wxid-alice": True}})
assert mapping._allowed_users == frozenset()
assert not mapping._check_user("wxid-alice")
@pytest.mark.parametrize("separator", [",", ", ", " ", "\t", "\n", "\u2003"])
def test_scalar_ids_with_separators_warn_but_remain_one_id(self, separator, caplog):
literal_id = f"wxid-alice{separator}wxid-bob"
with caplog.at_level(logging.WARNING, logger="app.channels.wechat"):
channel = self._channel({"allowed_users": literal_id})
assert channel._check_user(literal_id)
assert not channel._check_user("wxid-alice")
assert not channel._check_user("wxid-bob")
assert not channel._check_user("w")
assert "one literal user ID" in caplog.text
assert "YAML list" in caplog.text
assert "wxid-alice" not in caplog.text
assert "wxid-bob" not in caplog.text
@pytest.mark.parametrize("user_id", ["wxid-alice", " \twxid-alice\n "])
def test_scalar_id_padding_does_not_warn(self, user_id, caplog):
with caplog.at_level(logging.WARNING, logger="app.channels.wechat"):
channel = self._channel({"allowed_users": user_id})
assert channel._check_user("wxid-alice")
assert caplog.records == []
def test_gate_uses_ilink_user_id_and_skips_media_for_strangers(self):
async def go():
bus = MessageBus()
published = []
async def capture(msg):
published.append(msg)
bus.publish_inbound = capture # type: ignore[method-assign]
channel = self._channel({"allowed_users": ["wxid-alice"]})
channel.bus = bus
channel._extract_inbound_files = AsyncMock(return_value=[]) # type: ignore[method-assign]
stranger = {
"message_type": 1,
"ilink_user_id": "wxid-stranger",
"context_token": "ctx-stranger",
"item_list": [{"type": 1, "text_item": {"text": "hello"}}, {"type": 2, "image_item": {"media": {}}}],
}
member = {
"message_type": 1,
"ilink_user_id": "wxid-alice",
"context_token": "ctx-alice",
"item_list": [{"type": 1, "text_item": {"text": "hello"}}],
}
await channel._handle_update(stranger)
await channel._handle_update(member)
assert [msg.user_id for msg in published] == ["wxid-alice"]
channel._extract_inbound_files.assert_awaited_once_with(member)
_run(go())
def test_unreadable_allowlist_drops_chat_and_command_before_media_download(self):
async def go():
bus = MessageBus()
published = []
async def capture(msg):
published.append(msg)
bus.publish_inbound = capture # type: ignore[method-assign]
channel = self._channel({"allowed_users": [""]})
channel.bus = bus
channel._extract_inbound_files = AsyncMock(return_value=[{"name": "pic"}]) # type: ignore[method-assign]
for text in ("hello", "/status"):
await channel._handle_update(
{
"message_type": 1,
"from_user_id": "wxid-stranger",
"context_token": "ctx-denied",
"item_list": [{"type": 1, "text_item": {"text": text}}, {"type": 2, "image_item": {"media": {}}}],
}
)
assert published == []
channel._extract_inbound_files.assert_not_awaited()
_run(go())
def test_connect_code_is_consumed_when_allowlist_denies_everyone(self):
async def go():
bus = MessageBus()
published = []
async def capture(msg):
published.append(msg)
bus.publish_inbound = capture # type: ignore[method-assign]
channel = self._channel({"allowed_users": [""], "connection_repo": object()})
channel.bus = bus
channel._bind_connection_from_connect_code = AsyncMock(return_value=True) # type: ignore[method-assign]
channel._extract_inbound_files = AsyncMock(return_value=[{"name": "pic"}]) # type: ignore[method-assign]
await channel._handle_update(
{
"message_type": 1,
"from_user_id": "wxid-stranger",
"context_token": "ctx-connect",
"item_list": [{"type": 1, "text_item": {"text": "/connect one-time-fixture"}}],
}
)
channel._bind_connection_from_connect_code.assert_awaited_once_with(
chat_id="wxid-stranger",
context_token="ctx-connect",
code="one-time-fixture",
)
channel._extract_inbound_files.assert_not_awaited()
assert published == []
_run(go())
def test_connect_code_bypasses_allowed_users_filter(tmp_path: Path):
from app.channels.wechat import WechatChannel
from deerflow.persistence.channel_connections import ChannelConnectionRepository, ChannelCredentialCipher

View File

@ -2977,7 +2977,7 @@ run_ownership:
# ilink_app_id: ""
# # Optional: sent as SKRouteTag header when provided
# route_tag: ""
# allowed_users: [] # empty = allow all
# allowed_users: [] # iLink user IDs; empty allows all; a single ID is one entry; unparseable non-empty lists fail closed
# # Optional: extra host suffixes inbound media downloads may come from,
# # in addition to the platform CDN defaults (*.qq.com and the cdn_base_url host).
# # Entries are suffixes: "example.com" and "*.example.com" are equivalent.