| /* |
| * Licensed to the Apache Software Foundation (ASF) under one |
| * or more contributor license agreements. See the NOTICE file |
| * distributed with this work for additional information |
| * regarding copyright ownership. The ASF licenses this file |
| * to you under the Apache License, Version 2.0 (the |
| * "License"); you may not use this file except in compliance |
| * with the License. You may obtain a copy of the License at |
| * |
| * http://www.apache.org/licenses/LICENSE-2.0 |
| * |
| * Unless required by applicable law or agreed to in writing, |
| * software distributed under the License is distributed on an |
| * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY |
| * KIND, either express or implied. See the License for the |
| * specific language governing permissions and limitations |
| * under the License. |
| */ |
| |
| import { createRequire } from 'node:module'; |
| import { type BotChannelSettings, type BotProvider } from '@maka/core/bot-chat-settings'; |
| import type { BotTestResult } from './types.js'; |
| import { proxiedFetch } from './proxied-fetch.js'; |
| import { botDiagnosticMessage } from './base-adapter.js'; |
| import { |
| normalizeWechatIlinkBaseUrl, |
| testWechatBridge, |
| testWechatIlinkCredentials, |
| } from './wechat-bridge.js'; |
| |
| const BOT_TEST_TIMEOUT_MS = 10_000; |
| |
| /** |
| * PR1197 review (P1-4): Feishu and Lark share one Maka channel but live on |
| * different open-platform hosts. Brand-switch by the persisted channel domain, |
| * mirroring the onboarding flow, so a Lark tenant is not probed against the |
| * feishu.cn host. |
| */ |
| export function feishuOpenApiHost(domain: string | undefined): string { |
| return domain?.trim() === 'larksuite.com' ? 'open.larksuite.com' : 'open.feishu.cn'; |
| } |
| |
| export async function testBotChannel( |
| provider: BotProvider, |
| channel: BotChannelSettings, |
| ): Promise<BotTestResult> { |
| const result = await probeBotChannel(provider, channel); |
| if (result.ok) return result; |
| const errorCode = result.errorCode ?? 'connection_failed'; |
| const error = result.error ? botDiagnosticMessage(channel, result.error) : undefined; |
| if (error) console.warn(`[bots:${provider}] ${errorCode}: ${error}`); |
| return { ...result, errorCode, ...(error ? { error } : {}) }; |
| } |
| |
| async function probeBotChannel( |
| provider: BotProvider, |
| channel: BotChannelSettings, |
| ): Promise<BotTestResult> { |
| if ( |
| provider !== 'feishu' && |
| provider !== 'wecom' && |
| provider !== 'wechat' && |
| provider !== 'dingtalk' && |
| provider !== 'qq' && |
| provider !== 'slack' && |
| !channel.token.trim() |
| ) { |
| return { ok: false, errorCode: 'token_missing' }; |
| } |
| switch (provider) { |
| case 'telegram': |
| return testTelegram(channel); |
| case 'discord': |
| return testDiscord(channel); |
| case 'feishu': |
| return testFeishu(channel); |
| case 'wecom': |
| return testWeCom(channel); |
| case 'dingtalk': |
| return testDingTalk(channel); |
| case 'wechat': |
| return testWechat(channel); |
| case 'qq': |
| return testQQ(channel); |
| case 'slack': |
| return testSlack(channel); |
| } |
| } |
| |
| async function testSlack(channel: BotChannelSettings): Promise<BotTestResult> { |
| const botToken = channel.token.trim(); |
| const appToken = channel.appSecret?.trim() ?? ''; |
| if (!botToken || !appToken) { |
| return { ok: false, errorCode: 'slack_tokens_missing' }; |
| } |
| try { |
| const { WebClient } = createRequire(import.meta.url)( |
| '@slack/web-api', |
| ) as typeof import('@slack/web-api'); |
| const identity = await new WebClient(botToken).auth.test(); |
| if (!identity.ok) return { ok: false, error: identity.error ?? 'Slack auth.test failed' }; |
| const socket = await new WebClient(appToken).apps.connections.open(); |
| if (!socket.ok || !socket.url) { |
| return { ok: false, error: socket.error ?? 'Slack Socket Mode connection failed' }; |
| } |
| return { |
| ok: true, |
| identity: { |
| id: identity.user_id ?? identity.bot_id ?? identity.team_id ?? 'slack', |
| ...(identity.user ? { username: identity.user, displayName: identity.user } : {}), |
| }, |
| capabilities: { auth: true, socketMode: true }, |
| }; |
| } catch (error) { |
| return { ok: false, error: error instanceof Error ? error.message : String(error) }; |
| } |
| } |
| |
| async function testWechat(channel: BotChannelSettings): Promise<BotTestResult> { |
| if (normalizeWechatIlinkBaseUrl(channel.webhookUrl)) { |
| return testWechatIlinkCredentials(channel); |
| } |
| const appId = channel.appId?.trim() ?? ''; |
| const appSecret = channel.appSecret?.trim() || channel.token.trim(); |
| if (!appId || !appSecret) { |
| return testWechatBridge(channel); |
| } |
| try { |
| const url = new URL('https://api.weixin.qq.com/cgi-bin/token'); |
| url.searchParams.set('grant_type', 'client_credential'); |
| url.searchParams.set('appid', appId); |
| url.searchParams.set('secret', appSecret); |
| const response = await proxiedFetch(url.toString(), { |
| method: 'GET', |
| timeoutMs: BOT_TEST_TIMEOUT_MS, |
| }); |
| const json = await response.json().catch(() => ({})); |
| if (!response.ok || typeof json.access_token !== 'string') { |
| return { ok: false, error: json.errmsg ?? `HTTP ${response.status}` }; |
| } |
| return { |
| ok: true, |
| identity: { id: appId, username: appId, displayName: appId }, |
| capabilities: { auth: true }, |
| }; |
| } catch (error) { |
| return { ok: false, error: error instanceof Error ? error.message : String(error) }; |
| } |
| } |
| |
| async function testTelegram(channel: BotChannelSettings): Promise<BotTestResult> { |
| const base = `https://api.telegram.org/bot${channel.token}`; |
| try { |
| const response = await proxiedFetch(`${base}/getMe`, { |
| method: 'GET', |
| timeoutMs: BOT_TEST_TIMEOUT_MS, |
| }); |
| const me = await response.json().catch(() => null); |
| if (!response.ok || me?.ok !== true) |
| return { |
| ok: false, |
| errorCode: |
| response.status === 401 || (response.ok && me?.error_code === 401) |
| ? 'token_invalid' |
| : 'connection_failed', |
| error: |
| typeof me?.description === 'string' && me.description.trim() |
| ? me.description |
| : `Telegram getMe failed (HTTP ${response.status})`, |
| }; |
| return { |
| ok: true, |
| identity: { |
| id: String(me.result.id), |
| username: me.result.username, |
| displayName: me.result.first_name, |
| }, |
| messageSent: false, |
| }; |
| } catch (error) { |
| return { ok: false, error: error instanceof Error ? error.message : String(error) }; |
| } |
| } |
| |
| async function testDiscord(channel: BotChannelSettings): Promise<BotTestResult> { |
| try { |
| const response = await proxiedFetch('https://discord.com/api/v10/users/@me', { |
| method: 'GET', |
| headers: { Authorization: `Bot ${channel.token}` }, |
| timeoutMs: BOT_TEST_TIMEOUT_MS, |
| }); |
| const json = await response.json().catch(() => ({})); |
| if (!response.ok) return { ok: false, error: json.message ?? `HTTP ${response.status}` }; |
| return { |
| ok: true, |
| identity: { id: json.id, username: json.username, displayName: json.global_name }, |
| }; |
| } catch (error) { |
| return { ok: false, error: error instanceof Error ? error.message : String(error) }; |
| } |
| } |
| |
| /** |
| * PR1197 review (P1-4): WeCom onboarding + the live bridge now use the |
| * enterprise-WeChat **AI bot** credentials, stored as: |
| * - `appId` = AI 应用 Bot ID |
| * - `appSecret` = AI 应用 Secret |
| * |
| * The previous probe issued a corp `gettoken` (corp_id + corp_secret), which is |
| * a DIFFERENT credential shape. Running it against valid AI-bot credentials |
| * fails with an errcode, and the settings layer would then persist |
| * `connected: false` over a channel whose credentials are actually good — |
| * breaking a working channel with a wrong-endpoint test. |
| * |
| * The `@wecom/aibot-node-sdk` verifies credentials only through its WebSocket |
| * auth handshake (`WSAuthFailureError`); there is no cheap REST endpoint to |
| * validate a botId/secret pair without opening a live connection. So this probe |
| * validates shape/non-empty only and marks the result `verified: false`. The |
| * live bridge status (surfaced via readiness) remains the authority on whether |
| * the connection is actually up; the settings layer must not downgrade it on |
| * the strength of this unverified check. |
| */ |
| async function testWeCom(channel: BotChannelSettings): Promise<BotTestResult> { |
| const botId = channel.appId?.trim() ?? ''; |
| const secret = channel.appSecret?.trim() ?? ''; |
| if (!botId || !secret) { |
| return { ok: false, errorCode: 'wecom_credentials_missing', verified: false }; |
| } |
| return { |
| ok: true, |
| verified: false, |
| identity: { id: botId, username: botId, displayName: botId }, |
| capabilities: { auth: false }, |
| }; |
| } |
| |
| /** |
| * PR-BOT-DINGTALK-CREDENTIALS-TEST-0 (external bot research: enterprise IM |
| * adapters): verify DingTalk (钉钉) self-built app credentials by |
| * issuing an `access_token` via the open-platform `gettoken` endpoint. |
| * Mirrors the WeCom pattern almost exactly — the open platform exposes |
| * the same handshake shape with `appkey` / `appsecret`. |
| * |
| * Storage semantics (matches WeCom + Feishu): |
| * - `appId` = appkey (the self-built app's identifier) |
| * - `appSecret` = appsecret (the self-built app's secret) |
| * |
| * Success only proves the credentials exist; it does NOT prove that |
| * message send / receive will work — that needs DingTalk's outgoing |
| * group webhook or the Stream interface, which lands separately. |
| */ |
| async function testDingTalk(channel: BotChannelSettings): Promise<BotTestResult> { |
| const appkey = channel.appId?.trim() ?? ''; |
| const appsecret = channel.appSecret?.trim() ?? ''; |
| if (!appkey || !appsecret) { |
| return { ok: false, errorCode: 'dingtalk_credentials_missing' }; |
| } |
| const url = |
| 'https://oapi.dingtalk.com/gettoken?appkey=' + |
| encodeURIComponent(appkey) + |
| '&appsecret=' + |
| encodeURIComponent(appsecret); |
| try { |
| const response = await proxiedFetch(url, { |
| method: 'GET', |
| timeoutMs: BOT_TEST_TIMEOUT_MS, |
| }); |
| const json = await response.json().catch(() => ({})); |
| if (json.errcode && json.errcode !== 0) { |
| return { |
| ok: false, |
| error: json.errmsg ? String(json.errmsg) : `errcode ${json.errcode}`, |
| }; |
| } |
| if (typeof json.access_token !== 'string' || json.access_token.length === 0) { |
| return { ok: false, errorCode: 'dingtalk_no_access_token' }; |
| } |
| return { |
| ok: true, |
| identity: { id: appkey, username: appkey, displayName: appkey }, |
| capabilities: { auth: true }, |
| }; |
| } catch (error) { |
| return { ok: false, error: error instanceof Error ? error.message : String(error) }; |
| } |
| } |
| |
| /** |
| * PR-BOT-QQ-CREDENTIALS-TEST-0 (external bot research: official QQ Channel |
| * bot): verify QQ 官方机器人 self-built app credentials by issuing an |
| * `access_token` via the bots open-platform endpoint. Same handshake |
| * shape as WeCom / DingTalk — `appId` + `clientSecret`, returns a |
| * short-lived bot access token. |
| * |
| * Storage semantics (matches the existing self-built app pattern): |
| * - `appId` = QQ Bot App ID |
| * - `appSecret` = QQ Bot Client Secret |
| * |
| * Success only proves the credentials exist; it does NOT prove that |
| * the bot can receive events (that needs WebSocket Gateway connection) |
| * or send messages (that needs channel context + per-channel API). |
| */ |
| async function testQQ(channel: BotChannelSettings): Promise<BotTestResult> { |
| const appId = channel.appId?.trim() ?? ''; |
| const clientSecret = channel.appSecret?.trim() ?? ''; |
| if (!appId || !clientSecret) { |
| return { ok: false, errorCode: 'qq_credentials_missing' }; |
| } |
| try { |
| const response = await proxiedFetch('https://bots.qq.com/app/getAppAccessToken', { |
| method: 'POST', |
| headers: { 'Content-Type': 'application/json' }, |
| body: JSON.stringify({ appId, clientSecret }), |
| timeoutMs: BOT_TEST_TIMEOUT_MS, |
| }); |
| const json = await response.json().catch(() => ({})); |
| if (!response.ok) { |
| const message = |
| typeof json.message === 'string' && json.message.length > 0 |
| ? json.message |
| : `HTTP ${response.status}`; |
| return { ok: false, error: message }; |
| } |
| if (typeof json.access_token !== 'string' || json.access_token.length === 0) { |
| return { ok: false, errorCode: 'qq_no_access_token' }; |
| } |
| return { |
| ok: true, |
| identity: { id: appId, username: appId, displayName: appId }, |
| capabilities: { auth: true }, |
| }; |
| } catch (error) { |
| return { ok: false, error: error instanceof Error ? error.message : String(error) }; |
| } |
| } |
| |
| async function testFeishu(channel: BotChannelSettings): Promise<BotTestResult> { |
| const appId = channel.appId ?? ''; |
| const appSecret = channel.appSecret || channel.token; |
| if (!appId || !appSecret) return { ok: false, errorCode: 'feishu_credentials_missing' }; |
| // PR1197 review (P1-4): brand-switch the account host by the channel domain, |
| // exactly like the onboarding flow does. A Lark tenant (larksuite.com) cannot |
| // issue a tenant_access_token from the feishu.cn host, so a hardcoded host |
| // wrongly fails a valid Lark channel. |
| const host = feishuOpenApiHost(channel.domain); |
| try { |
| const response = await proxiedFetch( |
| `https://${host}/open-apis/auth/v3/tenant_access_token/internal`, |
| { |
| method: 'POST', |
| headers: { 'Content-Type': 'application/json' }, |
| body: JSON.stringify({ app_id: appId, app_secret: appSecret }), |
| timeoutMs: BOT_TEST_TIMEOUT_MS, |
| }, |
| ); |
| const json = await response.json(); |
| if (json.code !== 0 || !json.tenant_access_token) { |
| return { ok: false, error: json.msg ?? 'Failed to issue tenant_access_token' }; |
| } |
| return { |
| ok: true, |
| identity: { id: appId, username: appId, displayName: appId }, |
| capabilities: { auth: true }, |
| }; |
| } catch (error) { |
| return { ok: false, error: error instanceof Error ? error.message : String(error) }; |
| } |
| } |