mirror of
https://github.com/hansjone/dsh-im-ops.git
synced 2026-10-16 15:10:45 +08:00
fix(feishu): honor proxy settings for REST and WebSocket
This commit is contained in:
parent
b9ceac0779
commit
f7d2fe7047
14 changed files with 467 additions and 188 deletions
|
|
@ -89,6 +89,8 @@ A GitHub-source installation fetches and builds a Git dependency directly. With
|
||||||
|
|
||||||
After installation, follow the built-in instructions on each channel page to scan a QR code or enter credentials. Secrets and Tokens are sent only to the local Harness Host and stored through its protected credential provider; status responses and bot lists never return them.
|
After installation, follow the built-in instructions on each channel page to scan a QR code or enter credentials. Secrets and Tokens are sent only to the local Harness Host and stored through its protected credential provider; status responses and bot lists never return them.
|
||||||
|
|
||||||
|
If this machine must use a forward proxy to reach Feishu, set `HTTPS_PROXY` to a full HTTP proxy URL before starting `dsh web` (for example, `http://proxy:8080`; lowercase `https_proxy` is also supported, with `HTTP_PROXY` accepted as a fallback), then restart the Host after changing it. Feishu registration and credential verification reuse the SDK's proxy-aware HTTP client, while the message WebSocket explicitly uses that proxy; the WebSocket path does not currently read `ALL_PROXY` or `NO_PROXY`.
|
||||||
|
|
||||||
| Default behavior | Description |
|
| Default behavior | Description |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| Bot workspace | Each bot stores its workspace independently. New bots start with the Host's current working directory, which can later be changed from the bot card. |
|
| Bot workspace | Each bot stores its workspace independently. New bots start with the Host's current working directory, which can later be changed from the bot card. |
|
||||||
|
|
|
||||||
|
|
@ -92,6 +92,8 @@ GitHub 源安装会直接拉取并构建 Git 依赖;pnpm 10 及以上版本可
|
||||||
|
|
||||||
安装后,在对应渠道页面按照内置引导完成扫码或凭据配置。所有 Secret 和 Token 只提交给本机 Harness Host,并写入受保护的凭据存储;状态接口和机器人列表不会回传这些凭据。
|
安装后,在对应渠道页面按照内置引导完成扫码或凭据配置。所有 Secret 和 Token 只提交给本机 Harness Host,并写入受保护的凭据存储;状态接口和机器人列表不会回传这些凭据。
|
||||||
|
|
||||||
|
如果本机必须通过正向代理访问飞书,请在启动 `dsh web` 前把 `HTTPS_PROXY` 设置为包含协议的 HTTP 代理 URL(例如 `http://proxy:8080`;也支持小写 `https_proxy`,并兼容使用 `HTTP_PROXY` 作为回退),修改后重启 Host。飞书注册和凭据验证会复用 SDK 的代理感知 HTTP 客户端,消息长连接会显式通过这个代理建立 WebSocket;长连接目前不读取 `ALL_PROXY` 或 `NO_PROXY`。
|
||||||
|
|
||||||
| 默认行为 | 说明 |
|
| 默认行为 | 说明 |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| 机器人工作区 | 每个机器人独立保存工作区。新机器人默认使用 Host 当时的工作目录;之后可在机器人卡片中修改。 |
|
| 机器人工作区 | 每个机器人独立保存工作区。新机器人默认使用 Host 当时的工作目录;之后可在机器人卡片中修改。 |
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@ The DingTalk device-authorization request sequence and AI Card streaming protoco
|
||||||
|
|
||||||
The WeCom QR-authorization request sequence is adapted from the official [`@wecom/wecom-openclaw-cli`](https://www.npmjs.com/package/@wecom/wecom-openclaw-cli) 1.1.0 package, whose npm metadata declares the ISC License. No CLI source or OpenClaw runtime is bundled in this package.
|
The WeCom QR-authorization request sequence is adapted from the official [`@wecom/wecom-openclaw-cli`](https://www.npmjs.com/package/@wecom/wecom-openclaw-cli) 1.1.0 package, whose npm metadata declares the ISC License. No CLI source or OpenClaw runtime is bundled in this package.
|
||||||
|
|
||||||
The Host bundle includes [`@larksuiteoapi/node-sdk`](https://github.com/larksuite/node-sdk) 1.73.0, [`@whiskeysockets/baileys`](https://github.com/WhiskeySockets/Baileys) 7.0.0-rc14, and their [`protobufjs`](https://github.com/protobufjs/protobuf.js) 7.6.5 runtime. The Lark SDK and Baileys are licensed under the MIT License. protobufjs is licensed under the BSD 3-Clause License; both license texts are reproduced below.
|
The Host bundle includes [`@larksuiteoapi/node-sdk`](https://github.com/larksuite/node-sdk) 1.73.0, [`@whiskeysockets/baileys`](https://github.com/WhiskeySockets/Baileys) 7.0.0-rc14, [`https-proxy-agent`](https://github.com/TooTallNate/proxy-agents) 5.0.1, and their [`protobufjs`](https://github.com/protobufjs/protobuf.js) 7.6.5 runtime. The Lark SDK, Baileys, and https-proxy-agent are licensed under the MIT License. protobufjs is licensed under the BSD 3-Clause License; both license texts are reproduced below.
|
||||||
|
|
||||||
This package depends at runtime on [`dingtalk-stream`](https://github.com/open-dingtalk/dingtalk-stream-sdk-nodejs) 2.1.4, [`@wecom/aibot-node-sdk`](https://github.com/WecomTeam/aibot-node-sdk) 1.0.7, [`@tencent-connect/qqbot-nodejs`](https://github.com/tencent-connect/qqbot) 1.0.4, and [`qrcode`](https://github.com/soldair/node-qrcode) 1.5.4. These packages are licensed under the MIT License; `dingtalk-stream` is copyright 2023 钉钉开放平台团队.
|
This package depends at runtime on [`dingtalk-stream`](https://github.com/open-dingtalk/dingtalk-stream-sdk-nodejs) 2.1.4, [`@wecom/aibot-node-sdk`](https://github.com/WecomTeam/aibot-node-sdk) 1.0.7, [`@tencent-connect/qqbot-nodejs`](https://github.com/tencent-connect/qqbot) 1.0.4, and [`qrcode`](https://github.com/soldair/node-qrcode) 1.5.4. These packages are licensed under the MIT License; `dingtalk-stream` is copyright 2023 钉钉开放平台团队.
|
||||||
|
|
||||||
|
|
|
||||||
298
lib/index.js
298
lib/index.js
File diff suppressed because one or more lines are too long
1
package-lock.json
generated
1
package-lock.json
generated
|
|
@ -22,6 +22,7 @@
|
||||||
"@larksuiteoapi/node-sdk": "1.73.0",
|
"@larksuiteoapi/node-sdk": "1.73.0",
|
||||||
"@whiskeysockets/baileys": "7.0.0-rc14",
|
"@whiskeysockets/baileys": "7.0.0-rc14",
|
||||||
"esbuild": "0.25.9",
|
"esbuild": "0.25.9",
|
||||||
|
"https-proxy-agent": "5.0.1",
|
||||||
"react": "18.3.1",
|
"react": "18.3.1",
|
||||||
"react-dom": "18.3.1",
|
"react-dom": "18.3.1",
|
||||||
"react-test-renderer": "18.3.1"
|
"react-test-renderer": "18.3.1"
|
||||||
|
|
|
||||||
|
|
@ -99,6 +99,7 @@
|
||||||
"@larksuiteoapi/node-sdk": "1.73.0",
|
"@larksuiteoapi/node-sdk": "1.73.0",
|
||||||
"@whiskeysockets/baileys": "7.0.0-rc14",
|
"@whiskeysockets/baileys": "7.0.0-rc14",
|
||||||
"esbuild": "0.25.9",
|
"esbuild": "0.25.9",
|
||||||
|
"https-proxy-agent": "5.0.1",
|
||||||
"react": "18.3.1",
|
"react": "18.3.1",
|
||||||
"react-dom": "18.3.1",
|
"react-dom": "18.3.1",
|
||||||
"react-test-renderer": "18.3.1"
|
"react-test-renderer": "18.3.1"
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ import { homedir } from 'node:os';
|
||||||
import { join, resolve } from 'node:path';
|
import { join, resolve } from 'node:path';
|
||||||
import { unlink } from 'node:fs/promises';
|
import { unlink } from 'node:fs/promises';
|
||||||
import * as Lark from '@larksuiteoapi/node-sdk';
|
import * as Lark from '@larksuiteoapi/node-sdk';
|
||||||
|
import HttpsProxyAgent from 'https-proxy-agent';
|
||||||
import { createConnectionSupervisor } from './connection-supervisor.mjs';
|
import { createConnectionSupervisor } from './connection-supervisor.mjs';
|
||||||
import { createHarnessCommandExecutor } from '../../harness-command-executor.mjs';
|
import { createHarnessCommandExecutor } from '../../harness-command-executor.mjs';
|
||||||
import { createHarnessSessionExecutors } from '../../harness-session-coordinator.mjs';
|
import { createHarnessSessionExecutors } from '../../harness-session-coordinator.mjs';
|
||||||
|
|
@ -22,6 +23,22 @@ import {
|
||||||
} from '../../../../src/channels/shared/bot-workspace-store.mjs';
|
} from '../../../../src/channels/shared/bot-workspace-store.mjs';
|
||||||
import { listAgentPresetCatalog } from '../../../../src/channels/shared/agent-preset.mjs';
|
import { listAgentPresetCatalog } from '../../../../src/channels/shared/agent-preset.mjs';
|
||||||
|
|
||||||
|
function webSocketProxyUrl(env) {
|
||||||
|
for (const key of ['https_proxy', 'HTTPS_PROXY', 'http_proxy', 'HTTP_PROXY']) {
|
||||||
|
const value = env?.[key];
|
||||||
|
if (typeof value === 'string' && value.trim()) return value.trim();
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createFeishuWebSocketAgent(
|
||||||
|
env,
|
||||||
|
createAgent = (url) => new HttpsProxyAgent(url),
|
||||||
|
) {
|
||||||
|
const proxyUrl = webSocketProxyUrl(env);
|
||||||
|
return proxyUrl ? createAgent(proxyUrl) : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
function harnessOrigin(webServer, configured) {
|
function harnessOrigin(webServer, configured) {
|
||||||
if (configured !== undefined) return new URL(configured);
|
if (configured !== undefined) return new URL(configured);
|
||||||
const port = webServer?.port;
|
const port = webServer?.port;
|
||||||
|
|
@ -61,7 +78,11 @@ export async function createProductionController(ctx, config = {}, internals = {
|
||||||
const SessionStateStore = internals.StateStore ?? StateStore;
|
const SessionStateStore = internals.StateStore ?? StateStore;
|
||||||
const Harness = internals.HarnessClient ?? HarnessClient;
|
const Harness = internals.HarnessClient ?? HarnessClient;
|
||||||
const Runtime = internals.FeishuRuntime ?? FeishuRuntime;
|
const Runtime = internals.FeishuRuntime ?? FeishuRuntime;
|
||||||
const verifyApp = internals.verifyFeishuApp ?? verifyFeishuApp;
|
const verify = internals.verifyFeishuApp ?? verifyFeishuApp;
|
||||||
|
const verifyApp = (options) => verify({
|
||||||
|
...options,
|
||||||
|
httpInstance: lark.defaultHttpInstance,
|
||||||
|
});
|
||||||
const createSupervisor = internals.createConnectionSupervisor ?? createConnectionSupervisor;
|
const createSupervisor = internals.createConnectionSupervisor ?? createConnectionSupervisor;
|
||||||
const logger = typeof ctx.logger === 'function'
|
const logger = typeof ctx.logger === 'function'
|
||||||
? ctx.logger('dsh-feishu')
|
? ctx.logger('dsh-feishu')
|
||||||
|
|
@ -127,6 +148,8 @@ export async function createProductionController(ctx, config = {}, internals = {
|
||||||
...(controlExecutor ? { controlExecutor } : {}),
|
...(controlExecutor ? { controlExecutor } : {}),
|
||||||
...(sessionMaintenanceExecutor ? { sessionMaintenanceExecutor } : {}),
|
...(sessionMaintenanceExecutor ? { sessionMaintenanceExecutor } : {}),
|
||||||
});
|
});
|
||||||
|
const proxyEnv = internals.proxyEnv ?? process.env;
|
||||||
|
const wsAgent = createFeishuWebSocketAgent(proxyEnv, internals.createProxyAgent);
|
||||||
|
|
||||||
const coreController = new Controller({
|
const coreController = new Controller({
|
||||||
registerApp: (options) => lark.registerApp(options),
|
registerApp: (options) => lark.registerApp(options),
|
||||||
|
|
@ -153,6 +176,7 @@ export async function createProductionController(ctx, config = {}, internals = {
|
||||||
harness: workspaceScope.harness,
|
harness: workspaceScope.harness,
|
||||||
state: workspaceScope.state,
|
state: workspaceScope.state,
|
||||||
replyTimeoutMs: config.replyTimeoutMs ?? 600_000,
|
replyTimeoutMs: config.replyTimeoutMs ?? 600_000,
|
||||||
|
...(wsAgent ? { wsAgent } : {}),
|
||||||
logger: {
|
logger: {
|
||||||
error: (...args) => logger.error?.(`[${botId ?? botConfig.id}]`, ...args),
|
error: (...args) => logger.error?.(`[${botId ?? botConfig.id}]`, ...args),
|
||||||
warn: (...args) => logger.warn?.(`[${botId ?? botConfig.id}]`, ...args),
|
warn: (...args) => logger.warn?.(`[${botId ?? botConfig.id}]`, ...args),
|
||||||
|
|
@ -190,6 +214,7 @@ export async function createProductionController(ctx, config = {}, internals = {
|
||||||
await supervisor.close();
|
await supervisor.close();
|
||||||
await controller.close();
|
await controller.close();
|
||||||
harness.stopManagedProcess();
|
harness.stopManagedProcess();
|
||||||
|
wsAgent?.destroy?.();
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -142,6 +142,7 @@ for (const [name, version] of Object.entries(directDependencies)) {
|
||||||
const bundledBuildDependencies = {
|
const bundledBuildDependencies = {
|
||||||
'@larksuiteoapi/node-sdk': '1.73.0',
|
'@larksuiteoapi/node-sdk': '1.73.0',
|
||||||
'@whiskeysockets/baileys': '7.0.0-rc14',
|
'@whiskeysockets/baileys': '7.0.0-rc14',
|
||||||
|
'https-proxy-agent': '5.0.1',
|
||||||
};
|
};
|
||||||
for (const [name, version] of Object.entries(bundledBuildDependencies)) {
|
for (const [name, version] of Object.entries(bundledBuildDependencies)) {
|
||||||
if (manifest.dependencies?.[name] !== undefined) {
|
if (manifest.dependencies?.[name] !== undefined) {
|
||||||
|
|
@ -157,8 +158,8 @@ if (lock.packages?.['node_modules/protobufjs']?.dev !== true) {
|
||||||
if (manifest.bin?.['dsh-im'] !== 'bin/dsh-im.mjs') {
|
if (manifest.bin?.['dsh-im'] !== 'bin/dsh-im.mjs') {
|
||||||
throw new Error('package manifest must publish the dsh-im executable');
|
throw new Error('package manifest must publish the dsh-im executable');
|
||||||
}
|
}
|
||||||
if (/(?:from\s*|import\s*\(|require\s*\()\s*["'](?:@larksuiteoapi\/node-sdk|@whiskeysockets\/baileys|protobufjs)(?:\/[^"']*)?["']/.test(host)) {
|
if (/(?:from\s*|import\s*\(|require\s*\()\s*["'](?:@larksuiteoapi\/node-sdk|@whiskeysockets\/baileys|https-proxy-agent|protobufjs)(?:\/[^"']*)?["']/.test(host)) {
|
||||||
throw new Error('host bundle must not import a bundled SDK or protobufjs at runtime');
|
throw new Error('host bundle must not import a bundled SDK, proxy agent, or protobufjs at runtime');
|
||||||
}
|
}
|
||||||
if ((executable.mode & 0o111) === 0) throw new Error('dsh-im CLI is not executable');
|
if ((executable.mode & 0o111) === 0) throw new Error('dsh-im CLI is not executable');
|
||||||
if (/private-bot-token|must-be-rolled-back|DEEPSEEK_API_KEY=/.test(client + host)) {
|
if (/private-bot-token|must-be-rolled-back|DEEPSEEK_API_KEY=/.test(client + host)) {
|
||||||
|
|
|
||||||
|
|
@ -3,15 +3,12 @@ function endpointFor(domain, path) {
|
||||||
return new URL(path, origin);
|
return new URL(path, origin);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function jsonResponse(response, operation) {
|
function jsonResponse(body, operation) {
|
||||||
let body;
|
if (!body || typeof body !== 'object' || Array.isArray(body)) {
|
||||||
try {
|
|
||||||
body = await response.json();
|
|
||||||
} catch {
|
|
||||||
throw new Error(`${operation} returned a non-JSON response`);
|
throw new Error(`${operation} returned a non-JSON response`);
|
||||||
}
|
}
|
||||||
if (!response.ok || body?.code !== 0) {
|
if (body.code !== 0) {
|
||||||
throw new Error(`${operation} failed: ${body?.msg || `HTTP ${response.status}`}`);
|
throw new Error(`${operation} failed: ${body.msg || `code ${body.code}`}`);
|
||||||
}
|
}
|
||||||
return body;
|
return body;
|
||||||
}
|
}
|
||||||
|
|
@ -21,26 +18,32 @@ export async function verifyFeishuApp({
|
||||||
appId,
|
appId,
|
||||||
appSecret,
|
appSecret,
|
||||||
domain = 'feishu',
|
domain = 'feishu',
|
||||||
fetchImpl = fetch,
|
httpInstance,
|
||||||
timeoutMs = 15000,
|
timeoutMs = 15000,
|
||||||
}) {
|
}) {
|
||||||
if (!appId || !appSecret) throw new Error('Feishu credentials are incomplete');
|
if (!appId || !appSecret) throw new Error('Feishu credentials are incomplete');
|
||||||
const tokenResponse = await fetchImpl(endpointFor(domain, '/open-apis/auth/v3/tenant_access_token/internal'), {
|
if (!httpInstance || typeof httpInstance.request !== 'function') {
|
||||||
|
throw new TypeError('Feishu verification requires an HTTP instance');
|
||||||
|
}
|
||||||
|
const tokenBody = jsonResponse(await httpInstance.request({
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
|
url: endpointFor(domain, '/open-apis/auth/v3/tenant_access_token/internal').href,
|
||||||
headers: { 'content-type': 'application/json; charset=utf-8' },
|
headers: { 'content-type': 'application/json; charset=utf-8' },
|
||||||
body: JSON.stringify({ app_id: appId, app_secret: appSecret }),
|
data: { app_id: appId, app_secret: appSecret },
|
||||||
signal: AbortSignal.timeout(timeoutMs),
|
signal: AbortSignal.timeout(timeoutMs),
|
||||||
});
|
timeout: timeoutMs,
|
||||||
const tokenBody = await jsonResponse(tokenResponse, 'Feishu authentication');
|
}), 'Feishu authentication');
|
||||||
if (!tokenBody.tenant_access_token) {
|
if (!tokenBody.tenant_access_token) {
|
||||||
throw new Error('Feishu authentication returned no tenant access token');
|
throw new Error('Feishu authentication returned no tenant access token');
|
||||||
}
|
}
|
||||||
|
|
||||||
const botResponse = await fetchImpl(endpointFor(domain, '/open-apis/bot/v3/info/'), {
|
const botBody = jsonResponse(await httpInstance.request({
|
||||||
|
method: 'GET',
|
||||||
|
url: endpointFor(domain, '/open-apis/bot/v3/info/').href,
|
||||||
headers: { authorization: `Bearer ${tokenBody.tenant_access_token}` },
|
headers: { authorization: `Bearer ${tokenBody.tenant_access_token}` },
|
||||||
signal: AbortSignal.timeout(timeoutMs),
|
signal: AbortSignal.timeout(timeoutMs),
|
||||||
});
|
timeout: timeoutMs,
|
||||||
const botBody = await jsonResponse(botResponse, 'Feishu bot verification');
|
}), 'Feishu bot verification');
|
||||||
const bot = botBody.bot ?? {};
|
const bot = botBody.bot ?? {};
|
||||||
return Object.freeze({
|
return Object.freeze({
|
||||||
appId,
|
appId,
|
||||||
|
|
|
||||||
|
|
@ -96,6 +96,7 @@ export class FeishuRuntime {
|
||||||
#replyTimeoutMs;
|
#replyTimeoutMs;
|
||||||
#connectTimeoutMs;
|
#connectTimeoutMs;
|
||||||
#requestTimeoutMs;
|
#requestTimeoutMs;
|
||||||
|
#wsAgent;
|
||||||
#logger;
|
#logger;
|
||||||
#repair;
|
#repair;
|
||||||
#client = null;
|
#client = null;
|
||||||
|
|
@ -122,6 +123,7 @@ export class FeishuRuntime {
|
||||||
replyTimeoutMs = 600000,
|
replyTimeoutMs = 600000,
|
||||||
connectTimeoutMs = 15000,
|
connectTimeoutMs = 15000,
|
||||||
requestTimeoutMs = DEFAULT_REQUEST_TIMEOUT_MS,
|
requestTimeoutMs = DEFAULT_REQUEST_TIMEOUT_MS,
|
||||||
|
wsAgent,
|
||||||
logger = console,
|
logger = console,
|
||||||
}) {
|
}) {
|
||||||
if (!lark) throw new Error('FeishuRuntime requires the Feishu SDK');
|
if (!lark) throw new Error('FeishuRuntime requires the Feishu SDK');
|
||||||
|
|
@ -152,6 +154,7 @@ export class FeishuRuntime {
|
||||||
this.#replyTimeoutMs = replyTimeoutMs;
|
this.#replyTimeoutMs = replyTimeoutMs;
|
||||||
this.#connectTimeoutMs = connectTimeoutMs;
|
this.#connectTimeoutMs = connectTimeoutMs;
|
||||||
this.#requestTimeoutMs = requestTimeoutMs;
|
this.#requestTimeoutMs = requestTimeoutMs;
|
||||||
|
this.#wsAgent = wsAgent;
|
||||||
this.#logger = logger;
|
this.#logger = logger;
|
||||||
this.#status = createBridgeStatus({ allowedSenderCount: normalizedOwners.length });
|
this.#status = createBridgeStatus({ allowedSenderCount: normalizedOwners.length });
|
||||||
}
|
}
|
||||||
|
|
@ -266,6 +269,7 @@ export class FeishuRuntime {
|
||||||
|
|
||||||
this.#wsClient = new this.#lark.WSClient({
|
this.#wsClient = new this.#lark.WSClient({
|
||||||
...larkConfig,
|
...larkConfig,
|
||||||
|
...(this.#wsAgent ? { agent: this.#wsAgent } : {}),
|
||||||
loggerLevel: this.#lark.LoggerLevel.info,
|
loggerLevel: this.#lark.LoggerLevel.info,
|
||||||
handshakeTimeoutMs: 15000,
|
handshakeTimeoutMs: 15000,
|
||||||
onReady: () => {
|
onReady: () => {
|
||||||
|
|
|
||||||
|
|
@ -2,24 +2,23 @@ import assert from 'node:assert/strict';
|
||||||
import test from 'node:test';
|
import test from 'node:test';
|
||||||
import { verifyFeishuApp } from '../../../src/channels/feishu/feishu-app.mjs';
|
import { verifyFeishuApp } from '../../../src/channels/feishu/feishu-app.mjs';
|
||||||
|
|
||||||
function response(body, ok = true, status = 200) {
|
|
||||||
return { ok, status, async json() { return body; } };
|
|
||||||
}
|
|
||||||
|
|
||||||
test('verifyFeishuApp validates credentials and returns a safe bot identity', async () => {
|
test('verifyFeishuApp validates credentials and returns a safe bot identity', async () => {
|
||||||
const requests = [];
|
const requests = [];
|
||||||
const result = await verifyFeishuApp({
|
const result = await verifyFeishuApp({
|
||||||
appId: 'cli_test',
|
appId: 'cli_test',
|
||||||
appSecret: 'never-return-this',
|
appSecret: 'never-return-this',
|
||||||
fetchImpl: async (url, options) => {
|
timeoutMs: 1234,
|
||||||
requests.push({ url: String(url), options });
|
httpInstance: {
|
||||||
if (requests.length === 1) {
|
async request(options) {
|
||||||
return response({ code: 0, tenant_access_token: 'tenant-token' });
|
requests.push(options);
|
||||||
}
|
if (requests.length === 1) {
|
||||||
return response({
|
return { code: 0, tenant_access_token: 'tenant-token' };
|
||||||
code: 0,
|
}
|
||||||
bot: { app_name: '北汇星河助手', open_id: 'ou_bot', activate_status: 1 },
|
return {
|
||||||
});
|
code: 0,
|
||||||
|
bot: { app_name: '北汇星河助手', open_id: 'ou_bot', activate_status: 1 },
|
||||||
|
};
|
||||||
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -30,8 +29,16 @@ test('verifyFeishuApp validates credentials and returns a safe bot identity', as
|
||||||
activated: 1,
|
activated: 1,
|
||||||
});
|
});
|
||||||
assert.equal('appSecret' in result, false);
|
assert.equal('appSecret' in result, false);
|
||||||
assert.match(requests[0].options.body, /never-return-this/);
|
assert.equal(requests[0].method, 'POST');
|
||||||
assert.equal(requests[1].options.headers.authorization, 'Bearer tenant-token');
|
assert.equal(requests[0].url, 'https://open.feishu.cn/open-apis/auth/v3/tenant_access_token/internal');
|
||||||
|
assert.deepEqual(requests[0].data, { app_id: 'cli_test', app_secret: 'never-return-this' });
|
||||||
|
assert.equal(requests[0].timeout, 1234);
|
||||||
|
assert.ok(requests[0].signal instanceof AbortSignal);
|
||||||
|
assert.equal(requests[1].method, 'GET');
|
||||||
|
assert.equal(requests[1].url, 'https://open.feishu.cn/open-apis/bot/v3/info/');
|
||||||
|
assert.equal(requests[1].headers.authorization, 'Bearer tenant-token');
|
||||||
|
assert.equal(requests[1].timeout, 1234);
|
||||||
|
assert.ok(requests[1].signal instanceof AbortSignal);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('verifyFeishuApp rejects invalid credentials before reading bot info', async () => {
|
test('verifyFeishuApp rejects invalid credentials before reading bot info', async () => {
|
||||||
|
|
@ -39,10 +46,19 @@ test('verifyFeishuApp rejects invalid credentials before reading bot info', asyn
|
||||||
await assert.rejects(verifyFeishuApp({
|
await assert.rejects(verifyFeishuApp({
|
||||||
appId: 'cli_bad',
|
appId: 'cli_bad',
|
||||||
appSecret: 'bad',
|
appSecret: 'bad',
|
||||||
fetchImpl: async () => {
|
httpInstance: {
|
||||||
calls += 1;
|
async request() {
|
||||||
return response({ code: 10003, msg: 'invalid app secret' });
|
calls += 1;
|
||||||
|
return { code: 10003, msg: 'invalid app secret' };
|
||||||
|
},
|
||||||
},
|
},
|
||||||
}), /invalid app secret/);
|
}), /invalid app secret/);
|
||||||
assert.equal(calls, 1);
|
assert.equal(calls, 1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('verifyFeishuApp requires the shared SDK HTTP instance', async () => {
|
||||||
|
await assert.rejects(verifyFeishuApp({
|
||||||
|
appId: 'cli_test',
|
||||||
|
appSecret: 'secret',
|
||||||
|
}), /requires an HTTP instance/);
|
||||||
|
});
|
||||||
|
|
|
||||||
199
test/channels/feishu/feishu-proxy.test.mjs
Normal file
199
test/channels/feishu/feishu-proxy.test.mjs
Normal file
|
|
@ -0,0 +1,199 @@
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { once } from 'node:events';
|
||||||
|
import { createServer, request } from 'node:http';
|
||||||
|
import { connect } from 'node:net';
|
||||||
|
import test from 'node:test';
|
||||||
|
import * as Lark from '@larksuiteoapi/node-sdk';
|
||||||
|
import { createFeishuWebSocketAgent } from '../../../plugin-src/host/channels/feishu/production.mjs';
|
||||||
|
import { verifyFeishuApp } from '../../../src/channels/feishu/feishu-app.mjs';
|
||||||
|
|
||||||
|
async function listen(server) {
|
||||||
|
server.listen(0, '127.0.0.1');
|
||||||
|
await once(server, 'listening');
|
||||||
|
return server.address().port;
|
||||||
|
}
|
||||||
|
|
||||||
|
function close(server) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
server.close((error) => (error ? reject(error) : resolve()));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function restoreEnvironment(snapshot) {
|
||||||
|
for (const [key, value] of snapshot) {
|
||||||
|
if (value === undefined) delete process.env[key];
|
||||||
|
else process.env[key] = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
test('Feishu verification uses the SDK HTTP client through HTTPS_PROXY', {
|
||||||
|
timeout: 5000,
|
||||||
|
}, async () => {
|
||||||
|
const proxyHits = [];
|
||||||
|
const proxy = createServer((request, response) => {
|
||||||
|
proxyHits.push(request.url);
|
||||||
|
response.writeHead(502).end();
|
||||||
|
});
|
||||||
|
proxy.on('connect', (request, socket) => {
|
||||||
|
proxyHits.push(request.url);
|
||||||
|
socket.end('HTTP/1.1 502 Bad Gateway\r\nContent-Length: 0\r\n\r\n');
|
||||||
|
});
|
||||||
|
const proxyPort = await listen(proxy);
|
||||||
|
const environmentKeys = ['https_proxy', 'HTTPS_PROXY', 'no_proxy', 'NO_PROXY'];
|
||||||
|
const environment = new Map(environmentKeys.map((key) => [key, process.env[key]]));
|
||||||
|
|
||||||
|
try {
|
||||||
|
const proxyUrl = `http://127.0.0.1:${proxyPort}`;
|
||||||
|
process.env.https_proxy = proxyUrl;
|
||||||
|
process.env.HTTPS_PROXY = proxyUrl;
|
||||||
|
delete process.env.no_proxy;
|
||||||
|
delete process.env.NO_PROXY;
|
||||||
|
|
||||||
|
await assert.rejects(verifyFeishuApp({
|
||||||
|
appId: 'cli_0000000000000000',
|
||||||
|
appSecret: 'local-proxy-test-only',
|
||||||
|
httpInstance: Lark.defaultHttpInstance,
|
||||||
|
timeoutMs: 1000,
|
||||||
|
}));
|
||||||
|
} finally {
|
||||||
|
restoreEnvironment(environment);
|
||||||
|
await close(proxy);
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.equal(proxyHits.length, 1);
|
||||||
|
assert.match(proxyHits[0], /open\.feishu\.cn/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Lark WSClient sends its WSS handshake through the Feishu proxy agent', {
|
||||||
|
timeout: 5000,
|
||||||
|
}, async (t) => {
|
||||||
|
const connectTargets = [];
|
||||||
|
const proxy = createServer();
|
||||||
|
proxy.on('connect', (request, socket) => {
|
||||||
|
connectTargets.push(request.url);
|
||||||
|
socket.end('HTTP/1.1 502 Bad Gateway\r\nContent-Length: 0\r\n\r\n');
|
||||||
|
});
|
||||||
|
const proxyPort = await listen(proxy);
|
||||||
|
const agent = createFeishuWebSocketAgent({
|
||||||
|
HTTPS_PROXY: `http://127.0.0.1:${proxyPort}`,
|
||||||
|
});
|
||||||
|
const quietLogger = {
|
||||||
|
debug() {},
|
||||||
|
error() {},
|
||||||
|
info() {},
|
||||||
|
trace() {},
|
||||||
|
warn() {},
|
||||||
|
};
|
||||||
|
let failed;
|
||||||
|
const failure = new Promise((resolve, reject) => {
|
||||||
|
failed = { resolve, reject };
|
||||||
|
});
|
||||||
|
const wsClient = new Lark.WSClient({
|
||||||
|
agent,
|
||||||
|
appId: 'cli_0000000000000000',
|
||||||
|
appSecret: 'local-proxy-test-only',
|
||||||
|
autoReconnect: false,
|
||||||
|
handshakeTimeoutMs: 1000,
|
||||||
|
httpInstance: {
|
||||||
|
async request() {
|
||||||
|
return {
|
||||||
|
code: 0,
|
||||||
|
data: {
|
||||||
|
ClientConfig: {
|
||||||
|
PingInterval: 120,
|
||||||
|
ReconnectCount: 0,
|
||||||
|
ReconnectInterval: 1,
|
||||||
|
ReconnectNonce: 0,
|
||||||
|
},
|
||||||
|
URL: 'wss://msg-frontier.feishu.cn/ws/v2?device_id=local&service_id=1',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
},
|
||||||
|
logger: quietLogger,
|
||||||
|
loggerLevel: Lark.LoggerLevel.error,
|
||||||
|
onError: () => failed.resolve(),
|
||||||
|
onReady: () => failed.reject(new Error('The synthetic 502 proxy unexpectedly connected')),
|
||||||
|
});
|
||||||
|
t.after(() => {
|
||||||
|
wsClient.close({ force: true });
|
||||||
|
agent.destroy();
|
||||||
|
});
|
||||||
|
t.after(() => close(proxy));
|
||||||
|
|
||||||
|
await wsClient.start({ eventDispatcher: {} });
|
||||||
|
await failure;
|
||||||
|
|
||||||
|
assert.deepEqual(connectTargets, ['msg-frontier.feishu.cn:443']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Feishu WebSocket agent sends an upgrade through an HTTPS_PROXY CONNECT tunnel', {
|
||||||
|
timeout: 5000,
|
||||||
|
}, async (t) => {
|
||||||
|
let originSawUpgrade = false;
|
||||||
|
const origin = createServer();
|
||||||
|
origin.on('upgrade', (_request, socket) => {
|
||||||
|
originSawUpgrade = true;
|
||||||
|
socket.end([
|
||||||
|
'HTTP/1.1 101 Switching Protocols',
|
||||||
|
'Connection: Upgrade',
|
||||||
|
'Upgrade: websocket',
|
||||||
|
'',
|
||||||
|
'',
|
||||||
|
].join('\r\n'));
|
||||||
|
});
|
||||||
|
const originPort = await listen(origin);
|
||||||
|
|
||||||
|
const connectTargets = [];
|
||||||
|
const proxy = createServer();
|
||||||
|
proxy.on('connect', (proxyRequest, clientSocket, head) => {
|
||||||
|
connectTargets.push(proxyRequest.url);
|
||||||
|
const [hostname, port] = proxyRequest.url.split(':');
|
||||||
|
const upstream = connect({ host: hostname, port: Number(port) }, () => {
|
||||||
|
clientSocket.write('HTTP/1.1 200 Connection Established\r\n\r\n');
|
||||||
|
if (head.length > 0) upstream.write(head);
|
||||||
|
upstream.pipe(clientSocket);
|
||||||
|
clientSocket.pipe(upstream);
|
||||||
|
});
|
||||||
|
upstream.on('error', (error) => clientSocket.destroy(error));
|
||||||
|
clientSocket.on('error', () => upstream.destroy());
|
||||||
|
});
|
||||||
|
const proxyPort = await listen(proxy);
|
||||||
|
|
||||||
|
const agent = createFeishuWebSocketAgent({
|
||||||
|
HTTPS_PROXY: `http://127.0.0.1:${proxyPort}`,
|
||||||
|
});
|
||||||
|
t.after(() => agent.destroy());
|
||||||
|
t.after(async () => {
|
||||||
|
await Promise.all([close(proxy), close(origin)]);
|
||||||
|
});
|
||||||
|
|
||||||
|
await new Promise((resolve, reject) => {
|
||||||
|
const upgradeRequest = request({
|
||||||
|
agent,
|
||||||
|
headers: {
|
||||||
|
connection: 'Upgrade',
|
||||||
|
upgrade: 'websocket',
|
||||||
|
},
|
||||||
|
hostname: '127.0.0.1',
|
||||||
|
path: '/',
|
||||||
|
port: originPort,
|
||||||
|
});
|
||||||
|
upgradeRequest.once('upgrade', (_response, socket) => {
|
||||||
|
socket.destroy();
|
||||||
|
resolve();
|
||||||
|
});
|
||||||
|
upgradeRequest.once('response', (response) => {
|
||||||
|
reject(new Error(`Expected an upgrade, received HTTP ${response.statusCode}`));
|
||||||
|
});
|
||||||
|
upgradeRequest.once('error', reject);
|
||||||
|
upgradeRequest.end();
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(originSawUpgrade, true);
|
||||||
|
assert.deepEqual(connectTargets, [`127.0.0.1:${originPort}`]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Feishu WebSocket agent is absent when no proxy is configured', () => {
|
||||||
|
assert.equal(createFeishuWebSocketAgent({}), undefined);
|
||||||
|
});
|
||||||
|
|
@ -84,10 +84,12 @@ function fakeLark() {
|
||||||
test('FeishuRuntime becomes chat-ready only after Harness and Feishu are connected', async () => {
|
test('FeishuRuntime becomes chat-ready only after Harness and Feishu are connected', async () => {
|
||||||
let harnessChecks = 0;
|
let harnessChecks = 0;
|
||||||
let harnessSignal;
|
let harnessSignal;
|
||||||
|
const wsAgent = { addRequest() {} };
|
||||||
const runtime = new FeishuRuntime({
|
const runtime = new FeishuRuntime({
|
||||||
lark: fakeLark(),
|
lark: fakeLark(),
|
||||||
appId: 'cli_test',
|
appId: 'cli_test',
|
||||||
appSecret: 'secret',
|
appSecret: 'secret',
|
||||||
|
wsAgent,
|
||||||
ownerOpenIds: ['*', 'ou_owner'],
|
ownerOpenIds: ['*', 'ou_owner'],
|
||||||
harness: {
|
harness: {
|
||||||
async ensureRunning(options) {
|
async ensureRunning(options) {
|
||||||
|
|
@ -107,6 +109,8 @@ test('FeishuRuntime becomes chat-ready only after Harness and Feishu are connect
|
||||||
await new Promise((resolve) => setImmediate(resolve));
|
await new Promise((resolve) => setImmediate(resolve));
|
||||||
assert.equal(settled, false);
|
assert.equal(settled, false);
|
||||||
assert.equal(runtime.status.feishuLongConnectionState, 'connecting');
|
assert.equal(runtime.status.feishuLongConnectionState, 'connecting');
|
||||||
|
assert.equal(FakeWSClient.instances[0].options.agent, wsAgent);
|
||||||
|
assert.equal('agent' in FakeClient.instances[0].options, false);
|
||||||
FakeWSClient.instances[0].becomeReady();
|
FakeWSClient.instances[0].becomeReady();
|
||||||
const status = await starting;
|
const status = await starting;
|
||||||
assert.equal(harnessChecks, 1);
|
assert.equal(harnessChecks, 1);
|
||||||
|
|
|
||||||
|
|
@ -1126,6 +1126,11 @@ test('DSH credential adapter stores refs off the browser plane and clears them',
|
||||||
|
|
||||||
test('production assembly needs only ctx credentials and the active DSH webServer', async () => {
|
test('production assembly needs only ctx credentials and the active DSH webServer', async () => {
|
||||||
const constructed = {};
|
const constructed = {};
|
||||||
|
const httpInstance = { request: async () => ({}) };
|
||||||
|
const wsAgent = {
|
||||||
|
addRequest() {},
|
||||||
|
destroy() { constructed.wsAgentDestroyed = true; },
|
||||||
|
};
|
||||||
class FakeConfigStore {
|
class FakeConfigStore {
|
||||||
constructor(path) { constructed.configPath = path; }
|
constructor(path) { constructed.configPath = path; }
|
||||||
async load() { return this; }
|
async load() { return this; }
|
||||||
|
|
@ -1160,13 +1165,22 @@ test('production assembly needs only ctx credentials and the active DSH webServe
|
||||||
dshHome: '/tmp/dsh-feishu-host-test',
|
dshHome: '/tmp/dsh-feishu-host-test',
|
||||||
workspace: '/tmp/dsh-feishu-workspace',
|
workspace: '/tmp/dsh-feishu-workspace',
|
||||||
}, {
|
}, {
|
||||||
lark: { registerApp: async () => ({}) },
|
lark: { registerApp: async () => ({}), defaultHttpInstance: httpInstance },
|
||||||
Controller: FakeController,
|
Controller: FakeController,
|
||||||
ConfigStore: FakeConfigStore,
|
ConfigStore: FakeConfigStore,
|
||||||
StateStore: FakeStateStore,
|
StateStore: FakeStateStore,
|
||||||
HarnessClient: FakeHarness,
|
HarnessClient: FakeHarness,
|
||||||
FeishuRuntime: FakeRuntime,
|
FeishuRuntime: FakeRuntime,
|
||||||
verifyFeishuApp: async () => ({}),
|
verifyFeishuApp: async (options) => {
|
||||||
|
constructed.verifyOptions = options;
|
||||||
|
return {};
|
||||||
|
},
|
||||||
|
proxyEnv: { HTTPS_PROXY: 'http://proxy.test:8080' },
|
||||||
|
createProxyAgent: (proxyUrl) => {
|
||||||
|
constructed.wsAgentCreated = (constructed.wsAgentCreated ?? 0) + 1;
|
||||||
|
constructed.wsProxyUrl = proxyUrl;
|
||||||
|
return wsAgent;
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
assert.equal(constructed.initialized, undefined);
|
assert.equal(constructed.initialized, undefined);
|
||||||
|
|
@ -1174,6 +1188,10 @@ test('production assembly needs only ctx credentials and the active DSH webServe
|
||||||
assert.equal(constructed.initialized, true);
|
assert.equal(constructed.initialized, true);
|
||||||
assert.equal(constructed.harnessReadyChecks, 1);
|
assert.equal(constructed.harnessReadyChecks, 1);
|
||||||
assert.equal(constructed.controller.credentials, credentials);
|
assert.equal(constructed.controller.credentials, credentials);
|
||||||
|
await constructed.controller.verifyApp({ appId: 'cli_verify', appSecret: 'verify-secret' });
|
||||||
|
assert.equal(constructed.verifyOptions.httpInstance, httpInstance);
|
||||||
|
assert.equal(constructed.wsAgentCreated, 1);
|
||||||
|
assert.equal(constructed.wsProxyUrl, 'http://proxy.test:8080');
|
||||||
assert.equal(String(constructed.harness.baseUrl), 'http://127.0.0.1:43123/');
|
assert.equal(String(constructed.harness.baseUrl), 'http://127.0.0.1:43123/');
|
||||||
assert.equal(constructed.harness.autostart, false);
|
assert.equal(constructed.harness.autostart, false);
|
||||||
assert.match(constructed.configPath, /integrations\/dsh-feishu\/config\.json$/);
|
assert.match(constructed.configPath, /integrations\/dsh-feishu\/config\.json$/);
|
||||||
|
|
@ -1188,6 +1206,7 @@ test('production assembly needs only ctx credentials and the active DSH webServe
|
||||||
});
|
});
|
||||||
assert.match(constructed.statePath, /integrations\/dsh-feishu\/state\.json$/);
|
assert.match(constructed.statePath, /integrations\/dsh-feishu\/state\.json$/);
|
||||||
assert.equal(constructed.runtime.appSecret, 'host-only');
|
assert.equal(constructed.runtime.appSecret, 'host-only');
|
||||||
|
assert.equal(constructed.runtime.wsAgent, wsAgent);
|
||||||
const repair = { start() {}, status() {}, cancel() {} };
|
const repair = { start() {}, status() {}, cancel() {} };
|
||||||
await constructed.controller.createRuntime({
|
await constructed.controller.createRuntime({
|
||||||
botId: 'bot_alpha',
|
botId: 'bot_alpha',
|
||||||
|
|
@ -1222,6 +1241,7 @@ test('production assembly needs only ctx credentials and the active DSH webServe
|
||||||
await production.close();
|
await production.close();
|
||||||
assert.equal(constructed.closed, true);
|
assert.equal(constructed.closed, true);
|
||||||
assert.equal(constructed.harnessStopped, true);
|
assert.equal(constructed.harnessStopped, true);
|
||||||
|
assert.equal(constructed.wsAgentDestroyed, true);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('a corrupt legacy state file cannot prevent a healthy v2 bot from starting', async () => {
|
test('a corrupt legacy state file cannot prevent a healthy v2 bot from starting', async () => {
|
||||||
|
|
@ -1276,6 +1296,7 @@ test('a corrupt legacy state file cannot prevent a healthy v2 bot from starting'
|
||||||
HarnessClient: FakeHarness,
|
HarnessClient: FakeHarness,
|
||||||
FeishuRuntime: FakeRuntime,
|
FeishuRuntime: FakeRuntime,
|
||||||
verifyFeishuApp: async () => ({}),
|
verifyFeishuApp: async () => ({}),
|
||||||
|
proxyEnv: {},
|
||||||
});
|
});
|
||||||
|
|
||||||
await production.ready;
|
await production.ready;
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue