refactor(channels): introduce ExternalThreadId newtype at channel boundary (#2685)

* refactor(channels): introduce ExternalThreadId newtype at channel boundary

External channel thread ids (Telegram chat id, web UUID, Slack thread_ts)
flow as raw Option<String> through IncomingMessage, StatusUpdate, and
pending-gate store. Wraps them in a validated ExternalThreadId so the
compiler distinguishes boundary-layer ids from the internal ThreadId(Uuid).

Maps to bug pattern from #2349, #2444, #2517 where thread-id confusion
crossed a layer silently.

* fix(bridge): adapt test thread_id to ExternalThreadId newtype

Post-merge fix: a test added in staging (insert_and_notify_pending_gate_uses_extension_manager_for_auth_display_name) assigned a raw String to message.thread_id, but the field type became ExternalThreadId on this branch. Wrap with ExternalThreadId::from_trusted to match the other tests in the same module.

* refactor(types): address review feedback — byte units, shared validate, try_-variants, dedup pending-gate

* refactor(types): validate scope_thread_id + relay respond prefers typed msg.thread_id

- router.rs: scope_thread_id written to PendingGate was wrapped via
  ExternalThreadId::from_trusted from message.conversation_scope(), which
  can carry untrusted WASM/metadata-sourced strings. Now validates via
  ExternalThreadId::new; invalid values log at debug and store None.
  Applied at both call sites (authentication-fallback path and generic
  gate-insertion path).
- relay/channel.rs: respond() derived thread_id only from response or
  metadata — now also consults the validated msg.thread_id as the second
  fallback (before raw metadata) and filters empty strings so we never
  emit thread_ts: "" to Slack.
This commit is contained in:
Illia Polosukhin
2026-04-20 15:29:04 +09:00
committed by GitHub
parent 0476a3d8e9
commit 833cb4844f
24 changed files with 785 additions and 99 deletions

View File

@@ -461,7 +461,7 @@ async fn test_private_messages_use_chat_id_as_thread_scope() {
.await
.expect("message should arrive")
.expect("stream should yield a message");
assert_eq!(msg.thread_id.as_deref(), Some("999"));
assert_eq!(msg.thread_id.as_ref().map(|t| t.as_str()), Some("999"));
assert_eq!(msg.conversation_scope(), Some("999"));
}
@@ -572,7 +572,7 @@ async fn test_private_dm_webhook_and_reply_use_fake_telegram_api() {
.expect("message should arrive")
.expect("stream should yield a message");
assert_eq!(incoming.content, "hello from telegram dm");
assert_eq!(incoming.thread_id.as_deref(), Some("999"));
assert_eq!(incoming.thread_id.as_ref().map(|t| t.as_str()), Some("999"));
channel
.respond(
@@ -832,7 +832,10 @@ async fn test_group_message_with_bot_mention_emits_cleaned_content() {
.expect("message should arrive")
.expect("stream should yield a message");
assert_eq!(msg.content, "status please");
assert_eq!(msg.thread_id.as_deref(), Some("-123456789"));
assert_eq!(
msg.thread_id.as_ref().map(|t| t.as_str()),
Some("-123456789")
);
}
#[tokio::test]
@@ -938,7 +941,7 @@ async fn test_edited_message_emits_like_regular_message() {
.expect("message should arrive")
.expect("stream should yield a message");
assert_eq!(msg.content, "edited telegram message");
assert_eq!(msg.thread_id.as_deref(), Some("999"));
assert_eq!(msg.thread_id.as_ref().map(|t| t.as_str()), Some("999"));
}
#[tokio::test]
@@ -2032,7 +2035,7 @@ async fn test_polling_mode_get_updates_via_fake_telegram_api() {
.expect("stream should yield the polled message");
assert_eq!(msg.content, "hello from polling");
assert_eq!(msg.thread_id.as_deref(), Some("999"));
assert_eq!(msg.thread_id.as_ref().map(|t| t.as_str()), Some("999"));
// Trigger a second poll (should return empty, no new messages)
channel

View File

@@ -173,7 +173,7 @@ async fn test_ws_message_reaches_agent() {
.expect("Agent channel closed");
assert_eq!(incoming.content, "hello from ws");
assert_eq!(incoming.thread_id.as_deref(), Some("t42"));
assert_eq!(incoming.thread_id.as_ref().map(|t| t.as_str()), Some("t42"));
assert_eq!(incoming.channel, "gateway");
assert_eq!(incoming.user_id, "test-user");