feat(im): Add user-owned IM channel connections (#3487)

* Add user-owned IM channel connections

* Fix dev startup and channel connect popup

* Use async channel connect flow

* Harden dev service daemon startup

* Support local IM channel connections

* Align IM connections with local channels

* Fix safe user id digest algorithm

* Address Copilot IM channel feedback

* Address IM channel review comments

* Support all integrated IM channel connections

* Format additional channel connection tests

* Keep unavailable channel connect buttons clickable

* Fix IM channel provider icons

* Add runtime setup for enabled IM channels

* Guard global shortcut key handling

* Keep configured IM channels editable

* Avoid password autofill for channel secrets

* Make channel threads visible to connection owners

* Persist IM runtime config locally

* Allow disconnecting runtime IM channels

* Route no-auth channel sessions to local user

* Use default user for auth-disabled local mode

* Show IM channel source on threads

* Prefill IM channel runtime config

* Reflect IM channel runtime health

* Ignore Feishu message read events

* Ignore Feishu non-content message events

* Let setup wizard enable IM channels

* Fix frontend formatting after merge

* Stabilize backend tests without local config

* Isolate channel runtime config tests

* Address channel connection review comments

* Use sha256 user buckets with legacy migration

* Ensure runtime IM channels are ready after restart

* Persist disconnected IM channel state

* Address channel connection review comments

* Address channel connection review findings

Frontend connect flow:
- Open the runtime-config dialog only when a provider still needs
  credentials; configured providers go straight to the connect flow, so
  the binding-code/deep-link path is reachable from the UI again.
- After saving credentials, continue into the connect flow when a user
  binding is still required (multi-user mode) instead of stopping at a
  "Connected" toast.
- Extract shared provider-state helpers to core/channels/provider-state
  and add unit + e2e coverage for the direct-connect and
  configure-then-connect paths.

Provider status semantics:
- Report connection_status from the user's newest connection row;
  with no binding it is not_connected, except in auth-disabled local
  mode where a configured running channel is effectively connected.

Concurrency and event-loop correctness:
- Offload ChannelRuntimeConfigStore construction and writes, channel
  service construction, and Slack connection replies to threads; add a
  tests/blocking_io/ anchor for the runtime-config handlers.
- Consume binding codes with a conditional UPDATE so a code can only be
  used once under concurrent workers; retry upsert_connection as an
  update when a concurrent insert wins the unique constraint.
- Serialize ensure_channel_ready per channel so concurrent provider
  polls cannot double-start a channel worker.

Config and migration hardening:
- Stop mutating the get_app_config()-cached Telegram provider config;
  the runtime store now owns the UI-entered bot username.
- Register channel_connections in STARTUP_ONLY_FIELDS with the
  standardized startup-only Field description.
- Match the legacy unsafe-id bucket by recomputing its exact SHA-1 name
  so another user's same-prefix bucket can never be migrated.
- Remove the unused Telegram process_webhook_update path and document
  src/core/channels in the frontend docs.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Address PR review comments on authz scoping and channel runtime

Security (review feedback from ShenAC-SAC):
- Scope internal-token callers to the connection owner carried in
  X-DeerFlow-Owner-User-Id instead of bypassing owner checks outright,
  in both require_permission(owner_check=True) and the stateless run
  endpoints. Internal callers keep access to their own and
  shared/legacy threads, and may claim a default-owned channel thread
  for its real owner, but a leaked internal token no longer grants
  cross-user thread access.
- Require admin privileges for POST/DELETE /api/channels/{provider}/
  runtime-config: runtime credentials and channel workers are
  instance-wide shared state (same model as the MCP config API).
  Read-only provider listing stays available to all users.

Performance (review feedback from willem-bd):
- Skip the redundant thread channel-metadata PATCH after the first
  successful backfill per thread.
- Reuse the per-connection Slack WebClient until its token changes
  instead of constructing one per outbound message.
- Reconcile channel readiness for all providers concurrently in
  GET /api/channels/providers.

Also resolve the code-quality unused-import flag in the blocking-io
anchor by pre-importing the channel service via importlib.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Fix prettier formatting in provider-state test

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Reconcile UI runtime channel config with config reload on restart

Main now reloads a channel's config.yaml entry on restart_channel()
(#3514, issue #3497). Adapt the user-owned connection flow to coexist:

- configure_channel() restarts with reload_config=False — the caller
  just supplied the authoritative config (browser-entered credentials
  that are never written to config.yaml), so a file reload must not
  clobber it with the stale on-disk entry.
- _load_channel_config() re-applies the UI runtime-store overlay used
  at startup, so an operator-triggered restart keeps browser-entered
  credentials for channels without a config.yaml entry and does not
  resurrect a channel disconnected from the UI.
- Offload the reload's disk IO (config.yaml + runtime store) with
  asyncio.to_thread, matching the blocking-IO policy on this branch.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
DanielWalnut
2026-06-12 15:24:58 +08:00
committed by GitHub
parent b8f5ed360f
commit aa015462a7
96 changed files with 8585 additions and 277 deletions
+2 -2
View File
@@ -1,8 +1,8 @@
import type { User } from "./types";
export const AUTH_DISABLED_USER: User = {
id: "e2e-user",
email: "e2e@test.local",
id: "default",
email: "default@test.local",
system_role: "admin",
needs_setup: false,
};
+117
View File
@@ -0,0 +1,117 @@
import { fetch } from "@/core/api/fetcher";
import { getBackendBaseURL } from "@/core/config";
import type {
ChannelConnectResponse,
ChannelConnection,
ChannelConnectionsResponse,
ChannelProviderId,
ChannelProvider,
ChannelProvidersResponse,
ChannelRuntimeConfigValues,
} from "./types";
function channelsUrl(path: string): string {
return `${getBackendBaseURL()}/api/channels${path}`;
}
async function throwChannelApiError(
response: Response,
fallback: string,
): Promise<never> {
const body = (await response.json().catch(() => ({}))) as {
detail?: unknown;
};
throw new Error(typeof body.detail === "string" ? body.detail : fallback);
}
export async function listChannelProviders(): Promise<ChannelProvidersResponse> {
const response = await fetch(channelsUrl("/providers"));
if (!response.ok) {
await throwChannelApiError(
response,
`Failed to load channel providers: ${response.statusText}`,
);
}
return response.json() as Promise<ChannelProvidersResponse>;
}
export async function listChannelConnections(): Promise<ChannelConnection[]> {
const response = await fetch(channelsUrl("/connections"));
if (!response.ok) {
await throwChannelApiError(
response,
`Failed to load channel connections: ${response.statusText}`,
);
}
const data = (await response.json()) as ChannelConnectionsResponse;
return data.connections;
}
export async function connectChannelProvider(
provider: ChannelProviderId,
): Promise<ChannelConnectResponse> {
const response = await fetch(
channelsUrl(`/${encodeURIComponent(provider)}/connect`),
{ method: "POST" },
);
if (!response.ok) {
await throwChannelApiError(
response,
`Failed to connect ${provider}: ${response.statusText}`,
);
}
return response.json() as Promise<ChannelConnectResponse>;
}
export async function configureChannelProvider(
provider: ChannelProviderId,
values: ChannelRuntimeConfigValues,
): Promise<ChannelProvider> {
const response = await fetch(
channelsUrl(`/${encodeURIComponent(provider)}/runtime-config`),
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ values }),
},
);
if (!response.ok) {
await throwChannelApiError(
response,
`Failed to configure ${provider}: ${response.statusText}`,
);
}
return response.json() as Promise<ChannelProvider>;
}
export async function disconnectChannelConnection(
connectionId: string,
): Promise<void> {
const response = await fetch(
channelsUrl(`/connections/${encodeURIComponent(connectionId)}`),
{ method: "DELETE" },
);
if (!response.ok) {
await throwChannelApiError(
response,
`Failed to disconnect channel: ${response.statusText}`,
);
}
}
export async function disconnectChannelProvider(
provider: ChannelProviderId,
): Promise<ChannelProvider> {
const response = await fetch(
channelsUrl(`/${encodeURIComponent(provider)}/runtime-config`),
{ method: "DELETE" },
);
if (!response.ok) {
await throwChannelApiError(
response,
`Failed to disconnect ${provider}: ${response.statusText}`,
);
}
return response.json() as Promise<ChannelProvider>;
}
+96
View File
@@ -0,0 +1,96 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
configureChannelProvider,
connectChannelProvider,
disconnectChannelConnection,
disconnectChannelProvider,
listChannelConnections,
listChannelProviders,
} from "./api";
import type { ChannelProviderId, ChannelRuntimeConfigValues } from "./types";
export const channelProviderQueryKey = ["channelProviders"] as const;
export const channelConnectionsQueryKey = ["channelConnections"] as const;
export function useChannelProviders() {
const { data, isLoading, error } = useQuery({
queryKey: channelProviderQueryKey,
queryFn: () => listChannelProviders(),
});
return {
enabled: data?.enabled ?? false,
providers: data?.providers ?? [],
isLoading,
error,
};
}
export function useChannelConnections() {
const { data, isLoading, error } = useQuery({
queryKey: channelConnectionsQueryKey,
queryFn: () => listChannelConnections(),
});
return { connections: data ?? [], isLoading, error };
}
export function useConnectChannelProvider() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (provider: ChannelProviderId) =>
connectChannelProvider(provider),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: channelProviderQueryKey });
void queryClient.invalidateQueries({
queryKey: channelConnectionsQueryKey,
});
},
});
}
export function useConfigureChannelProvider() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({
provider,
values,
}: {
provider: ChannelProviderId;
values: ChannelRuntimeConfigValues;
}) => configureChannelProvider(provider, values),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: channelProviderQueryKey });
void queryClient.invalidateQueries({
queryKey: channelConnectionsQueryKey,
});
},
});
}
export function useDisconnectChannelConnection() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (connectionId: string) =>
disconnectChannelConnection(connectionId),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: channelProviderQueryKey });
void queryClient.invalidateQueries({
queryKey: channelConnectionsQueryKey,
});
},
});
}
export function useDisconnectChannelProvider() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (provider: ChannelProviderId) =>
disconnectChannelProvider(provider),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: channelProviderQueryKey });
void queryClient.invalidateQueries({
queryKey: channelConnectionsQueryKey,
});
},
});
}
@@ -0,0 +1,27 @@
export type ChannelConnectWindow = Window | null;
export function prepareConnectWindow(): ChannelConnectWindow {
const opened = window.open("about:blank", "_blank");
if (opened) {
opened.opener = null;
}
return opened;
}
export function openConnectUrl(
url: string,
connectWindow: ChannelConnectWindow = prepareConnectWindow(),
) {
if (connectWindow && !connectWindow.closed) {
connectWindow.location.replace(url);
return;
}
window.location.assign(url);
}
export function closeConnectWindow(connectWindow: ChannelConnectWindow) {
if (connectWindow && !connectWindow.closed) {
connectWindow.close();
}
}
@@ -0,0 +1,22 @@
import type { ChannelProvider } from "./types";
export function providerCanConnect(provider: ChannelProvider): boolean {
return (
(provider.connectable ?? (provider.enabled && provider.configured)) &&
provider.connection_status !== "connected"
);
}
export function providerNeedsRuntimeConfig(provider: ChannelProvider): boolean {
return (
provider.enabled &&
!provider.configured &&
(provider.credential_fields?.length ?? 0) > 0
);
}
export function providerCanEditRuntimeConfig(
provider: ChannelProvider,
): boolean {
return provider.enabled && (provider.credential_fields?.length ?? 0) > 0;
}
+53
View File
@@ -0,0 +1,53 @@
export type ChannelProviderId = "telegram" | "slack" | "discord" | string;
export interface ChannelCredentialField {
name: string;
label: string;
type: string;
required: boolean;
}
export type ChannelRuntimeConfigValues = Record<string, string>;
export interface ChannelProvider {
provider: ChannelProviderId;
display_name: string;
enabled: boolean;
configured: boolean;
connectable?: boolean;
unavailable_reason?: string | null;
auth_mode: string;
connection_status: string;
credential_fields: ChannelCredentialField[];
credential_values?: ChannelRuntimeConfigValues;
}
export interface ChannelProvidersResponse {
enabled: boolean;
providers: ChannelProvider[];
}
export interface ChannelConnection {
id: string;
provider: ChannelProviderId;
status: string;
external_account_id?: string | null;
external_account_name?: string | null;
workspace_id?: string | null;
workspace_name?: string | null;
scopes: string[];
metadata: Record<string, unknown>;
}
export interface ChannelConnectionsResponse {
connections: ChannelConnection[];
}
export interface ChannelConnectResponse {
provider: ChannelProviderId;
mode: string;
url?: string | null;
code: string;
instruction: string;
expires_in: number;
}
+42
View File
@@ -170,6 +170,7 @@ export const enUS: Translations = {
sidebar: {
newChat: "New chat",
chats: "Chats",
channels: "Channels",
recentChats: "Recent chats",
demoChats: "Demo chats",
agents: "Agents",
@@ -259,6 +260,39 @@ export const enUS: Translations = {
loadOlderChats: "Load older chats",
},
// Channels
channels: {
title: "Channels",
connect: "Connect",
modify: "Modify",
reconnect: "Reconnect",
disconnect: "Disconnect",
connected: "Connected",
notConnected: "Not connected",
pending: "Pending",
revoked: "Disconnected",
disabled: "Disabled",
unconfigured: "Not configured",
unavailable: "Channel connections are unavailable right now.",
unavailableShort: "Unavailable",
setupTitle: (name: string) => `Connect ${name}`,
setupEditTitle: (name: string) => `Modify ${name}`,
setupDescription:
"Enter the values needed by this server process. They are not written to config.yaml.",
saveAndConnect: "Save and connect",
saveChanges: "Save changes",
descriptions: {
telegram: "Telegram direct messages through your DeerFlow bot.",
slack: "Slack workspace messages and mentions.",
discord: "Discord server messages through your DeerFlow bot.",
feishu: "Feishu and Lark messages through your DeerFlow app.",
dingtalk: "DingTalk Stream Push messages through your DeerFlow bot.",
wechat: "WeChat iLink messages through your DeerFlow bot.",
wecom: "WeCom messages through your DeerFlow AI bot.",
},
connectedAs: (name: string) => `Connected as ${name}.`,
},
// Page titles (document title)
pages: {
appName: "DeerFlow",
@@ -359,6 +393,7 @@ export const enUS: Translations = {
sections: {
account: "Account",
appearance: "Appearance",
channels: "Channels",
memory: "Memory",
tools: "Tools",
skills: "Skills",
@@ -461,6 +496,13 @@ export const enUS: Translations = {
title: "Tools",
description: "Manage the configuration and enabled status of MCP tools.",
},
channels: {
title: "Channels",
description:
"Connect IM accounts that can send messages to DeerFlow from outside the browser.",
disabled:
"Channel connections are not enabled on this server. Ask an administrator to enable channel_connections.",
},
skills: {
title: "Agent Skills",
description:
+31
View File
@@ -117,6 +117,7 @@ export interface Translations {
chats: string;
demoChats: string;
agents: string;
channels: string;
};
// Agents
@@ -190,6 +191,30 @@ export interface Translations {
loadOlderChats: string;
};
// Channels
channels: {
title: string;
connect: string;
modify: string;
reconnect: string;
disconnect: string;
connected: string;
notConnected: string;
pending: string;
revoked: string;
disabled: string;
unconfigured: string;
unavailable: string;
unavailableShort: string;
setupTitle: (name: string) => string;
setupEditTitle: (name: string) => string;
setupDescription: string;
saveAndConnect: string;
saveChanges: string;
descriptions: Record<string, string>;
connectedAs: (name: string) => string;
};
// Page titles (document title)
pages: {
appName: string;
@@ -286,6 +311,7 @@ export interface Translations {
sections: {
account: string;
appearance: string;
channels: string;
memory: string;
tools: string;
skills: string;
@@ -381,6 +407,11 @@ export interface Translations {
title: string;
description: string;
};
channels: {
title: string;
description: string;
disabled: string;
};
skills: {
title: string;
description: string;
+41
View File
@@ -164,6 +164,7 @@ export const zhCN: Translations = {
sidebar: {
newChat: "新对话",
chats: "对话",
channels: "渠道",
recentChats: "最近的对话",
demoChats: "演示对话",
agents: "智能体",
@@ -247,6 +248,39 @@ export const zhCN: Translations = {
loadOlderChats: "加载更早的对话",
},
// Channels
channels: {
title: "渠道",
connect: "连接",
modify: "修改",
reconnect: "重新连接",
disconnect: "断开连接",
connected: "已连接",
notConnected: "未连接",
pending: "待完成",
revoked: "已断开",
disabled: "已停用",
unconfigured: "未配置",
unavailable: "当前无法使用渠道连接。",
unavailableShort: "不可用",
setupTitle: (name: string) => `连接 ${name}`,
setupEditTitle: (name: string) => `修改 ${name}`,
setupDescription:
"填写当前服务进程需要的配置值。这些内容不会写入 config.yaml。",
saveAndConnect: "保存并连接",
saveChanges: "保存修改",
descriptions: {
telegram: "通过 DeerFlow Bot 接收 Telegram 私聊消息。",
slack: "接收 Slack 工作区消息和提及。",
discord: "通过 DeerFlow Bot 接收 Discord 服务器消息。",
feishu: "通过 DeerFlow 应用接收飞书和 Lark 消息。",
dingtalk: "通过 DeerFlow Bot 接收钉钉 Stream Push 消息。",
wechat: "通过 DeerFlow Bot 接收微信 iLink 消息。",
wecom: "通过 DeerFlow AI Bot 接收企业微信消息。",
},
connectedAs: (name: string) => `已连接为 ${name}`,
},
// Page titles (document title)
pages: {
appName: "DeerFlow",
@@ -343,6 +377,7 @@ export const zhCN: Translations = {
sections: {
account: "账号",
appearance: "外观",
channels: "渠道",
memory: "记忆",
tools: "工具",
skills: "技能",
@@ -442,6 +477,12 @@ export const zhCN: Translations = {
title: "工具",
description: "管理 MCP 工具的配置和启用状态。",
},
channels: {
title: "渠道",
description: "连接可在浏览器外向 DeerFlow 发送消息的即时通讯账号。",
disabled:
"当前服务器未启用渠道连接。请联系管理员开启 channel_connections。",
},
skills: {
title: "技能",
description: "管理 Agent Skill 配置和启用状态。",
+7 -60
View File
@@ -26,6 +26,11 @@ import type { UploadedFileInfo } from "../uploads";
import { promptInputFilePartToFile, uploadFiles } from "../uploads";
import { fetchThreadTokenUsage } from "./api";
import {
buildThreadsSearchQueryOptions,
DEFAULT_THREAD_SEARCH_PARAMS,
type ThreadSearchParams,
} from "./thread-search-query";
import { threadTokenUsageQueryKey } from "./token-usage";
import type {
AgentThread,
@@ -1201,69 +1206,11 @@ export function useThreadHistory(
}
export function useThreads(
params: Parameters<ThreadsClient["search"]>[0] = {
limit: 50,
sortBy: "updated_at",
sortOrder: "desc",
select: ["thread_id", "updated_at", "values", "metadata"],
},
params: ThreadSearchParams = DEFAULT_THREAD_SEARCH_PARAMS,
) {
const apiClient = getAPIClient();
return useQuery<AgentThread[]>({
queryKey: ["threads", "search", params],
queryFn: async () => {
const maxResults = params.limit;
const initialOffset = params.offset ?? 0;
const DEFAULT_PAGE_SIZE = 50;
// Preserve prior semantics: if a non-positive limit is explicitly provided,
// delegate to a single search call with the original parameters.
if (maxResults !== undefined && maxResults <= 0) {
const response =
await apiClient.threads.search<AgentThreadState>(params);
return response as AgentThread[];
}
const pageSize =
typeof maxResults === "number" && maxResults > 0
? Math.min(DEFAULT_PAGE_SIZE, maxResults)
: DEFAULT_PAGE_SIZE;
const threads: AgentThread[] = [];
let offset = initialOffset;
while (true) {
if (typeof maxResults === "number" && threads.length >= maxResults) {
break;
}
const currentLimit =
typeof maxResults === "number"
? Math.min(pageSize, maxResults - threads.length)
: pageSize;
if (typeof maxResults === "number" && currentLimit <= 0) {
break;
}
const response = (await apiClient.threads.search<AgentThreadState>({
...params,
limit: currentLimit,
offset,
})) as AgentThread[];
threads.push(...response);
if (response.length < currentLimit) {
break;
}
offset += response.length;
}
return threads;
},
refetchOnWindowFocus: false,
...buildThreadsSearchQueryOptions(apiClient, params),
});
}
@@ -0,0 +1,86 @@
import type { ThreadsClient } from "@langchain/langgraph-sdk/client";
import type { AgentThread, AgentThreadState } from "./types";
type ThreadsSearchClient = {
threads: {
search: ThreadsClient["search"];
};
};
export type ThreadSearchParams = NonNullable<
Parameters<ThreadsClient["search"]>[0]
>;
export const DEFAULT_THREAD_SEARCH_PARAMS: ThreadSearchParams = {
limit: 50,
sortBy: "updated_at",
sortOrder: "desc",
select: ["thread_id", "updated_at", "values", "metadata"],
};
export const THREAD_SEARCH_REFETCH_INTERVAL_MS = 5000;
export function buildThreadsSearchQueryOptions(
apiClient: ThreadsSearchClient,
params: ThreadSearchParams = DEFAULT_THREAD_SEARCH_PARAMS,
) {
return {
queryKey: ["threads", "search", params],
queryFn: async () => {
const maxResults = params.limit;
const initialOffset = params.offset ?? 0;
const DEFAULT_PAGE_SIZE = 50;
// Preserve prior semantics: if a non-positive limit is explicitly provided,
// delegate to a single search call with the original parameters.
if (maxResults !== undefined && maxResults <= 0) {
const response =
await apiClient.threads.search<AgentThreadState>(params);
return response as AgentThread[];
}
const pageSize =
typeof maxResults === "number" && maxResults > 0
? Math.min(DEFAULT_PAGE_SIZE, maxResults)
: DEFAULT_PAGE_SIZE;
const threads: AgentThread[] = [];
let offset = initialOffset;
while (true) {
if (typeof maxResults === "number" && threads.length >= maxResults) {
break;
}
const currentLimit =
typeof maxResults === "number"
? Math.min(pageSize, maxResults - threads.length)
: pageSize;
if (typeof maxResults === "number" && currentLimit <= 0) {
break;
}
const response = (await apiClient.threads.search<AgentThreadState>({
...params,
limit: currentLimit,
offset,
})) as AgentThread[];
threads.push(...response);
if (response.length < currentLimit) {
break;
}
offset += response.length;
}
return threads;
},
refetchInterval: THREAD_SEARCH_REFETCH_INTERVAL_MS,
refetchIntervalInBackground: false,
refetchOnWindowFocus: false,
};
}
+45
View File
@@ -2,6 +2,12 @@ import type { Message } from "@langchain/langgraph-sdk";
import type { AgentThread, AgentThreadContext } from "./types";
export type ChannelThreadSource = {
type: "im_channel";
provider: string;
label: string;
};
type ThreadRouteTarget =
| string
| {
@@ -49,3 +55,42 @@ export function textOfMessage(message: Message) {
export function titleOfThread(thread: AgentThread) {
return thread.values?.title ?? "Untitled";
}
const CHANNEL_PROVIDER_LABELS: Record<string, string> = {
dingtalk: "DingTalk",
discord: "Discord",
feishu: "Feishu",
slack: "Slack",
telegram: "Telegram",
wechat: "WeChat",
wecom: "WeCom",
};
function labelOfChannelProvider(provider: string) {
return CHANNEL_PROVIDER_LABELS[provider] ?? provider;
}
export function channelSourceOfThread(
thread: Pick<AgentThread, "metadata">,
): ChannelThreadSource | null {
const source = thread.metadata?.channel_source;
if (!source || typeof source !== "object" || Array.isArray(source)) {
return null;
}
if (Reflect.get(source, "type") !== "im_channel") {
return null;
}
const provider = Reflect.get(source, "provider");
if (typeof provider !== "string" || provider.trim().length === 0) {
return null;
}
const normalizedProvider = provider.trim().toLowerCase();
return {
type: "im_channel",
provider: normalizedProvider,
label: labelOfChannelProvider(normalizedProvider),
};
}