blob: 6e342c6b3c47b5028ffa09ab22e47eea186705c2 [file]
import { botDisplayLabel, type BotChannelSettings, type BotProvider } from '@maka/core';
import { generalizedErrorMessage } from '@maka/core/redaction';
import { WebClient } from '@slack/web-api';
import type { BotTestResult } from './types.js';
import { proxiedFetch } from './proxied-fetch.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> {
if (
provider !== 'feishu' &&
provider !== 'wecom' &&
provider !== 'wechat' &&
provider !== 'dingtalk' &&
provider !== 'qq' &&
provider !== 'slack' &&
provider !== 'whatsapp' &&
!channel.token.trim()
) {
return { ok: false, error: 'Bot token is required' };
}
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);
case 'whatsapp':
return testWhatsApp(channel);
}
}
async function testSlack(channel: BotChannelSettings): Promise<BotTestResult> {
const botToken = channel.token.trim();
const appToken = channel.appSecret?.trim() ?? '';
if (!botToken || !appToken) {
return { ok: false, error: 'Slack 需要 Bot Token 与 App-Level Token' };
}
try {
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 },
hint: '凭据有效,Socket Mode 连接可用。',
};
} catch (error) {
return { ok: false, error: generalizedErrorMessage(error) };
}
}
async function testWhatsApp(channel: BotChannelSettings): Promise<BotTestResult> {
if (!channel.sessionConfigured) {
return { ok: false, error: 'WhatsApp 尚未通过二维码关联设备', verified: false };
}
return {
ok: true,
verified: false,
capabilities: { localSession: true },
hint: '本机关联凭据已保存;实际可用性以运行态连接为准。',
};
}
async function testWechat(channel: BotChannelSettings): Promise<BotTestResult> {
if (channel.token.trim() && 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 },
hint: '凭据有效;消息收发还需要公众号服务器配置和回调验证。',
};
} 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 me = await (
await proxiedFetch(`${base}/getMe`, { method: 'GET', timeoutMs: BOT_TEST_TIMEOUT_MS })
).json();
if (!me.ok) return { ok: false, error: me.description ?? 'Invalid bot token' };
return {
ok: true,
identity: {
id: String(me.result.id),
username: me.result.username,
displayName: me.result.first_name,
},
messageSent: false,
hint: '发送 /start 给机器人后可在运行态接收消息。',
};
} 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, error: '企业微信 AI 机器人需要 Bot ID 与 Secret', verified: false };
}
return {
ok: true,
verified: false,
identity: { id: botId, username: botId, displayName: botId },
capabilities: { auth: false },
hint: '已保存凭据;企业微信 AI 机器人的连接状态以运行态长连接为准。',
};
}
/**
* 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, error: '钉钉需要 appkey 与 appsecret' };
}
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 ? `钉钉: ${json.errmsg}` : `钉钉 errcode ${json.errcode}`,
};
}
if (typeof json.access_token !== 'string' || json.access_token.length === 0) {
return { ok: false, error: '钉钉凭据测试未返回 access_token' };
}
return {
ok: true,
identity: { id: appkey, username: appkey, displayName: appkey },
capabilities: { auth: true },
hint: '凭据有效;接收消息需要 outgoing 机器人或 Stream 模式配置。',
};
} 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, error: 'QQ 官方机器人需要 App ID 与 Client Secret' };
}
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
? `QQ: ${json.message}`
: `HTTP ${response.status}`;
return { ok: false, error: message };
}
if (typeof json.access_token !== 'string' || json.access_token.length === 0) {
return { ok: false, error: 'QQ 官方机器人凭据测试未返回 access_token' };
}
return {
ok: true,
identity: { id: appId, username: appId, displayName: appId },
capabilities: { auth: true },
hint: '凭据有效;接收消息需要 QQ Gateway WebSocket 接入。',
};
} 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, error: 'Feishu appId and appSecret are required' };
// 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) };
}
}