ylib-syim 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +376 -0
- package/bin/syim.mjs +34 -0
- package/bridges/dingtalk-stdio-bridge.ts +10 -0
- package/bridges/lark-stdio-bridge.ts +10 -0
- package/bridges/logger.ts +47 -0
- package/bridges/main.ts +126 -0
- package/package.json +56 -0
- package/scripts/bridge-runtime-package.json +14 -0
- package/scripts/build-bridge.mjs +36 -0
- package/scripts/dev-server.ts +53 -0
- package/scripts/dingtalk-stdio-bridge.ts +309 -0
- package/scripts/dingtalk-stream-server.ts +173 -0
- package/scripts/example-feishu-webhook.ts +71 -0
- package/scripts/example-iflow-agent.ts +117 -0
- package/scripts/example-single-agent.ts +151 -0
- package/scripts/feishu-yuce-bridge.ts +226 -0
- package/scripts/install/README.md +98 -0
- package/scripts/install/im-agent-hub-ctl.sh +283 -0
- package/scripts/install/install-dingtalk-bridge.sh +314 -0
- package/scripts/install/systemd/im-agent-hub-dingtalk.service.example +18 -0
- package/scripts/install/uninstall-im-agent-hub.sh +140 -0
- package/scripts/lark-stdio-bridge.ts +387 -0
- package/scripts/start.sh +12 -0
- package/syim.json.bak +70 -0
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 钉钉桥接 esbuild 打包:
|
|
3
|
+
* - 默认:重型依赖外置 + minify,cjs 约 200KB+(未压缩源码约 400KB+)。
|
|
4
|
+
* - --all:全部打入单文件(~13MB → minify 后明显变小)。
|
|
5
|
+
* - --no-minify:便于对照报错行号(体积变大)。
|
|
6
|
+
*/
|
|
7
|
+
import * as esbuild from "esbuild";
|
|
8
|
+
import path from "node:path";
|
|
9
|
+
import { fileURLToPath } from "node:url";
|
|
10
|
+
|
|
11
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
12
|
+
const root = path.join(__dirname, "..");
|
|
13
|
+
|
|
14
|
+
/** 与 dingtalk-openclaw-connector 中重型依赖对齐;运行时必须存在于 node_modules */
|
|
15
|
+
const RUNTIME_EXTERNALS = [
|
|
16
|
+
"dingtalk-stream",
|
|
17
|
+
"pdf-parse",
|
|
18
|
+
"mammoth",
|
|
19
|
+
"fluent-ffmpeg",
|
|
20
|
+
"@ffmpeg-installer/ffmpeg",
|
|
21
|
+
"axios",
|
|
22
|
+
];
|
|
23
|
+
|
|
24
|
+
const all = process.argv.includes("--all");
|
|
25
|
+
const noMinify = process.argv.includes("--no-minify");
|
|
26
|
+
|
|
27
|
+
await esbuild.build({
|
|
28
|
+
entryPoints: [path.join(root, "scripts/dingtalk-stdio-bridge.ts")],
|
|
29
|
+
bundle: true,
|
|
30
|
+
platform: "node",
|
|
31
|
+
format: "cjs",
|
|
32
|
+
outfile: path.join(root, "dist/dingtalk-stdio-bridge.cjs"),
|
|
33
|
+
external: all ? [] : RUNTIME_EXTERNALS,
|
|
34
|
+
minify: !noMinify,
|
|
35
|
+
logLevel: "info",
|
|
36
|
+
});
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 本地测试用开发服务器:接收 HTTP POST 的 InboundMessage,经 Hub 处理后由钉钉适配器回发到钉钉。
|
|
3
|
+
* 使用可复用的 createInboundServer;可选文件 Session 持久化(PERSIST_SESSIONS=1 或 SESSION_DIR)。
|
|
4
|
+
*
|
|
5
|
+
* 使用方式:
|
|
6
|
+
* DINGTALK_CLIENT_ID=xxx DINGTALK_CLIENT_SECRET=yyy pnpm run dev
|
|
7
|
+
* PERSIST_SESSIONS=1 或 SESSION_DIR=/path/to/sessions 启用会话落盘
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import {
|
|
11
|
+
createHub,
|
|
12
|
+
createDingTalkAdapter,
|
|
13
|
+
createRuleBasedRouter,
|
|
14
|
+
createInMemorySessionStore,
|
|
15
|
+
createFileSessionStore,
|
|
16
|
+
createLangChainLikeAgent,
|
|
17
|
+
startInboundServer,
|
|
18
|
+
} from "../src/index.js";
|
|
19
|
+
|
|
20
|
+
const PORT = Number(process.env.PORT) || 3100;
|
|
21
|
+
|
|
22
|
+
const clientId = process.env.DINGTALK_CLIENT_ID;
|
|
23
|
+
const clientSecret = process.env.DINGTALK_CLIENT_SECRET;
|
|
24
|
+
|
|
25
|
+
if (!clientId || !clientSecret) {
|
|
26
|
+
console.error("请设置环境变量: DINGTALK_CLIENT_ID, DINGTALK_CLIENT_SECRET");
|
|
27
|
+
process.exit(1);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const sessions =
|
|
31
|
+
process.env.PERSIST_SESSIONS === "1" || process.env.SESSION_DIR
|
|
32
|
+
? createFileSessionStore(process.env.SESSION_DIR ? { dir: process.env.SESSION_DIR } : {})
|
|
33
|
+
: createInMemorySessionStore();
|
|
34
|
+
|
|
35
|
+
const echoAgent = createLangChainLikeAgent({
|
|
36
|
+
id: "main",
|
|
37
|
+
systemPrompt: "你是助手。回复简洁。",
|
|
38
|
+
historyLimit: 20,
|
|
39
|
+
runnable: async ({ messages }) => {
|
|
40
|
+
const last = messages[messages.length - 1];
|
|
41
|
+
return { content: `收到:${last?.content ?? ""}` };
|
|
42
|
+
},
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
const hub = createHub({
|
|
46
|
+
router: createRuleBasedRouter({ defaultAgentId: "main" }),
|
|
47
|
+
agents: [echoAgent],
|
|
48
|
+
adapters: [createDingTalkAdapter({ config: { clientId, clientSecret } })],
|
|
49
|
+
sessions,
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
startInboundServer({ hub, port: PORT });
|
|
53
|
+
console.log("peer.id 填钉钉 userId 或群 openConversationId;PERSIST_SESSIONS=1 可持久化会话");
|
|
@@ -0,0 +1,309 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 钉钉 Stdio 桥接:与 start:connector 行为一致,复用同一套 @dingtalk-real-ai/dingtalk-connector 插件。
|
|
3
|
+
* 不维护独立的消息/流式逻辑,插件新功能(如 asyncMode、AI Card、bindings)自动生效。
|
|
4
|
+
* 桥接层不改写 /v1/chat/completions 请求体,是否传 ext 由插件决定(当前通用契约为不传 ext,仅传 user)。
|
|
5
|
+
*
|
|
6
|
+
* 与 connector-host 的差异:不启动本地 HTTP Gateway,而是通过 cfg.channels.dingtalk-connector.gatewayBaseUrl
|
|
7
|
+
* 让插件直接请求远程 Gateway(如 yuce-gpt)。配置来源与 connector-host 相同:syim.json 或环境变量。
|
|
8
|
+
*
|
|
9
|
+
* 使用:在项目目录下配置 syim.json 的 channels.dingtalk-connector,或设置环境变量;
|
|
10
|
+
* 必须提供远程 Gateway 的 base URL:环境变量 DINGTALK_AGENT_URL / GATEWAY_URL,或 syim.json 中
|
|
11
|
+
* channels.dingtalk-connector 的 dingtalkAgentUrl / gatewayBaseUrl,或任一子账号 accounts.*.gatewayBaseUrl。
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import fs from "node:fs";
|
|
15
|
+
import os from "node:os";
|
|
16
|
+
import path from "node:path";
|
|
17
|
+
|
|
18
|
+
const CHANNEL_KEY = "dingtalk-connector";
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* 项目根(syim.json 所在目录)。
|
|
22
|
+
* 用 process.argv[1]:兼容 `tsx scripts/...ts`、`node dist/...cjs`,且 CJS 打包产物内无需 import.meta / __filename。
|
|
23
|
+
*/
|
|
24
|
+
function getProjectRoot(): string {
|
|
25
|
+
const main = process.argv[1];
|
|
26
|
+
if (main) {
|
|
27
|
+
const resolved = path.resolve(main);
|
|
28
|
+
const base = path.basename(resolved);
|
|
29
|
+
if (
|
|
30
|
+
base === "dingtalk-stdio-bridge.ts" ||
|
|
31
|
+
base === "dingtalk-stdio-bridge.cjs" ||
|
|
32
|
+
base === "dingtalk-stdio-bridge.mjs"
|
|
33
|
+
) {
|
|
34
|
+
return path.join(path.dirname(resolved), "..");
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
return process.cwd();
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** 在 `accounts` 记录中查找第一个非空字符串字段(用于 hub 式配置:Gateway 仅写在子账号下) */
|
|
41
|
+
function firstStringInAccounts(
|
|
42
|
+
accounts: Record<string, unknown> | undefined,
|
|
43
|
+
field: string,
|
|
44
|
+
): string {
|
|
45
|
+
if (!accounts || typeof accounts !== "object") return "";
|
|
46
|
+
for (const acc of Object.values(accounts)) {
|
|
47
|
+
if (!acc || typeof acc !== "object") continue;
|
|
48
|
+
const v = (acc as Record<string, unknown>)[field];
|
|
49
|
+
if (typeof v === "string" && v.trim()) return v.trim();
|
|
50
|
+
}
|
|
51
|
+
return "";
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function channelsForLog(channels: Record<string, unknown> | undefined): Record<string, unknown> | undefined {
|
|
55
|
+
if (!channels || typeof channels !== "object") return channels;
|
|
56
|
+
const out: Record<string, unknown> = {};
|
|
57
|
+
const sensitive = new Set(["clientSecret", "dingtalkAgentAccessToken", "token", "gatewayToken", "gatewayPassword"]);
|
|
58
|
+
for (const [k, v] of Object.entries(channels)) {
|
|
59
|
+
if (v && typeof v === "object") {
|
|
60
|
+
const c = { ...(v as Record<string, unknown>) };
|
|
61
|
+
for (const key of Object.keys(c)) if (sensitive.has(key)) (c as Record<string, unknown>)[key] = "<redacted>";
|
|
62
|
+
if (c.accounts && typeof c.accounts === "object") (c as Record<string, unknown>).accounts = Object.keys(c.accounts as object);
|
|
63
|
+
out[k] = c;
|
|
64
|
+
} else out[k] = v;
|
|
65
|
+
}
|
|
66
|
+
return out;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** 加载 syim.json,与 connector-host 一致 */
|
|
70
|
+
function loadOpenClawConfig(): Record<string, unknown> | null {
|
|
71
|
+
const configPaths = [path.join(getProjectRoot(), "syim.json"), path.join(os.homedir(), ".syim", "syim.json")];
|
|
72
|
+
for (const configPath of configPaths) {
|
|
73
|
+
if (!fs.existsSync(configPath)) continue;
|
|
74
|
+
try {
|
|
75
|
+
const content = fs.readFileSync(configPath, "utf-8");
|
|
76
|
+
const config = JSON.parse(content) as Record<string, unknown>;
|
|
77
|
+
console.error("[dingtalk-stdio-bridge] 配置与 connector 匹配: 从 syim.json 加载配置,路径:", configPath);
|
|
78
|
+
console.error("[dingtalk-stdio-bridge] 配置文件 channels:", JSON.stringify(channelsForLog(config.channels as Record<string, unknown>), null, 2));
|
|
79
|
+
return config;
|
|
80
|
+
} catch (err) {
|
|
81
|
+
console.warn("[dingtalk-stdio-bridge] 读取配置失败:", configPath, (err as Error).message);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
console.error("[dingtalk-stdio-bridge] 配置与 connector 匹配: 未找到 syim.json,将使用环境变量");
|
|
85
|
+
return null;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** 构建 cfg,与 connector-host buildDingTalkCfg 一致;并注入 gatewayBaseUrl 使插件直连远程 Gateway */
|
|
89
|
+
function buildCfg(): Record<string, unknown> {
|
|
90
|
+
const raw = loadOpenClawConfig();
|
|
91
|
+
const channelConfig = (raw?.channels as Record<string, unknown>)?.[CHANNEL_KEY] as Record<string, unknown> | undefined;
|
|
92
|
+
|
|
93
|
+
const envBase = (process.env.DINGTALK_AGENT_URL || process.env.GATEWAY_URL || "").trim();
|
|
94
|
+
const accountsMap = channelConfig?.accounts as Record<string, unknown> | undefined;
|
|
95
|
+
const fileBase =
|
|
96
|
+
(channelConfig?.dingtalkAgentUrl as string)?.trim() ||
|
|
97
|
+
(channelConfig?.gatewayBaseUrl as string)?.trim() ||
|
|
98
|
+
firstStringInAccounts(accountsMap, "gatewayBaseUrl") ||
|
|
99
|
+
"";
|
|
100
|
+
const baseUrlRaw = envBase || fileBase;
|
|
101
|
+
const gatewayBaseUrl = baseUrlRaw
|
|
102
|
+
? baseUrlRaw.replace(/\/dingtalk\/chat\/?$/i, "").replace(/\/v1\/chat\/completions\/?$/i, "").replace(/\/+$/, "")
|
|
103
|
+
: "";
|
|
104
|
+
const gatewayToken = (
|
|
105
|
+
process.env.GATEWAY_TOKEN ||
|
|
106
|
+
process.env.OPENCLAW_GATEWAY_TOKEN ||
|
|
107
|
+
process.env.OPENCLAW_GATEWAY_API_KEY ||
|
|
108
|
+
(channelConfig?.gatewayToken as string)?.trim() ||
|
|
109
|
+
firstStringInAccounts(accountsMap, "gatewayToken") ||
|
|
110
|
+
""
|
|
111
|
+
).trim();
|
|
112
|
+
console.log("[dingtalk-stdio-bridge] gatewayToken =", gatewayToken);
|
|
113
|
+
console.log("[dingtalk-stdio-bridge] gatewayBaseUrl =", gatewayBaseUrl);
|
|
114
|
+
|
|
115
|
+
if (!gatewayBaseUrl) {
|
|
116
|
+
console.error(JSON.stringify({ error: "DINGTALK_AGENT_URL or GATEWAY_URL (or syim.json dingtalkAgentUrl) required" }));
|
|
117
|
+
process.exit(1);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
let cfg: Record<string, unknown>;
|
|
121
|
+
|
|
122
|
+
if (channelConfig) {
|
|
123
|
+
const base = { ...channelConfig } as Record<string, unknown>;
|
|
124
|
+
const accounts = base.accounts as Record<string, Record<string, unknown>> | undefined;
|
|
125
|
+
delete base.accounts;
|
|
126
|
+
base.gatewayBaseUrl = gatewayBaseUrl;
|
|
127
|
+
if (gatewayToken) base.gatewayToken = gatewayToken;
|
|
128
|
+
if (accounts && typeof accounts === "object") {
|
|
129
|
+
const merged: Record<string, Record<string, unknown>> = {};
|
|
130
|
+
for (const [id, acc] of Object.entries(accounts)) {
|
|
131
|
+
if (acc && typeof acc === "object") merged[id] = { ...base, ...acc };
|
|
132
|
+
}
|
|
133
|
+
cfg = { channels: { [CHANNEL_KEY]: { ...base, accounts: merged } } };
|
|
134
|
+
} else {
|
|
135
|
+
cfg = { channels: { [CHANNEL_KEY]: base } };
|
|
136
|
+
}
|
|
137
|
+
console.error("[dingtalk-stdio-bridge] 配置与 connector 匹配: channel 键 \"" + CHANNEL_KEY + "\" 在 cfg.channels 中找到");
|
|
138
|
+
} else {
|
|
139
|
+
const clientId = process.env.DINGTALK_CLIENT_ID;
|
|
140
|
+
const clientSecret = process.env.DINGTALK_CLIENT_SECRET;
|
|
141
|
+
if (!clientId || !clientSecret) {
|
|
142
|
+
console.error(JSON.stringify({ error: "DINGTALK_CLIENT_ID and DINGTALK_CLIENT_SECRET required (set in syim.json channels.dingtalk-connector or env)" }));
|
|
143
|
+
process.exit(1);
|
|
144
|
+
}
|
|
145
|
+
console.error("[dingtalk-stdio-bridge] 配置与 connector 匹配: 无配置文件或未匹配 channel,使用环境变量");
|
|
146
|
+
cfg = {
|
|
147
|
+
channels: {
|
|
148
|
+
[CHANNEL_KEY]: {
|
|
149
|
+
clientId,
|
|
150
|
+
clientSecret,
|
|
151
|
+
gatewayBaseUrl,
|
|
152
|
+
gatewayToken: gatewayToken || undefined,
|
|
153
|
+
separateSessionByConversation: true,
|
|
154
|
+
groupSessionScope: "group",
|
|
155
|
+
debug: process.env.DEBUG === "1",
|
|
156
|
+
},
|
|
157
|
+
},
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
console.error("[dingtalk-stdio-bridge] 配置与 connector 匹配: gatewayBaseUrl =", gatewayBaseUrl, "(插件将直连此 base + /v1/chat/completions)");
|
|
162
|
+
console.error("[dingtalk-stdio-bridge] 配置与 connector 匹配: 桥接就绪,加载插件 @dingtalk-real-ai/dingtalk-connector 与 start:connector 行为一致");
|
|
163
|
+
return { cfg, gatewayBaseUrl, gatewayToken };
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* 方案 A:在导入插件前设置临时 HOME,使插件内读取 ~/.syim/syim.json 时实际读到当前项目 syim.json(用于 bindings 等)。
|
|
168
|
+
* 不劫持 fs,仅改环境变量 + 临时目录内放一份配置(拷贝),进程内 os.homedir() 指向该临时目录。
|
|
169
|
+
*/
|
|
170
|
+
function setupOpenClawConfigForPlugin(): void {
|
|
171
|
+
const projectRoot = getProjectRoot();
|
|
172
|
+
const projectConfigPath = path.join(projectRoot, "syim.json");
|
|
173
|
+
if (!fs.existsSync(projectConfigPath)) return;
|
|
174
|
+
|
|
175
|
+
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-bridge-"));
|
|
176
|
+
const openclawDir = path.join(tempDir, ".syim");
|
|
177
|
+
fs.mkdirSync(openclawDir, { recursive: true });
|
|
178
|
+
const targetPath = path.join(openclawDir, "syim.json");
|
|
179
|
+
try {
|
|
180
|
+
fs.copyFileSync(projectConfigPath, targetPath);
|
|
181
|
+
} catch (err) {
|
|
182
|
+
console.warn("[dingtalk-stdio-bridge] 拷贝 syim.json 到临时目录失败,插件将使用系统 ~/.syim:", (err as Error).message);
|
|
183
|
+
try {
|
|
184
|
+
fs.rmSync(tempDir, { recursive: true, force: true });
|
|
185
|
+
} catch (_) {}
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
188
|
+
process.env.HOME = tempDir;
|
|
189
|
+
if (process.platform === "win32") process.env.USERPROFILE = tempDir;
|
|
190
|
+
console.error("[dingtalk-stdio-bridge] 已设置 HOME 为临时目录,插件将读取当前项目 syim.json(bindings 等):", projectConfigPath);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
async function main(): Promise<void> {
|
|
194
|
+
setupOpenClawConfigForPlugin();
|
|
195
|
+
|
|
196
|
+
const { cfg, gatewayBaseUrl, gatewayToken } = buildCfg();
|
|
197
|
+
|
|
198
|
+
// 确保插件 getConfig(cfg) 拿到的对象上就有 gatewayBaseUrl:直接写入 cfg.channels[CHANNEL_KEY]
|
|
199
|
+
const ch = (cfg as Record<string, unknown>).channels as Record<string, unknown> | undefined;
|
|
200
|
+
const channelEntry = ch?.[CHANNEL_KEY];
|
|
201
|
+
if (channelEntry && typeof channelEntry === "object") {
|
|
202
|
+
(channelEntry as Record<string, unknown>).gatewayBaseUrl = gatewayBaseUrl;
|
|
203
|
+
if (gatewayToken) (channelEntry as Record<string, unknown>).gatewayToken = gatewayToken;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
const runtime = {
|
|
207
|
+
gateway: { port: 0 },
|
|
208
|
+
channel: { activity: { record: (_ch: string, _acc: string, _ev: string) => {} } },
|
|
209
|
+
};
|
|
210
|
+
|
|
211
|
+
console.error("[dingtalk-stdio-bridge] 插件与 connector 匹配: 开始加载插件包 ylib-dingtalk-connector");
|
|
212
|
+
const connectorModule = await import("ylib-dingtalk-connector");
|
|
213
|
+
const plugin = connectorModule.default as { register: (api: unknown) => void };
|
|
214
|
+
const dingtalkPlugin = connectorModule.dingtalkPlugin as {
|
|
215
|
+
config?: { listAccountIds?: (cfg: unknown) => string[]; resolveAccount?: (cfg: unknown, id?: string) => unknown; isConfigured?: (a: unknown) => boolean };
|
|
216
|
+
gateway?: { startAccount?: (ctx: unknown) => Promise<unknown> };
|
|
217
|
+
} | undefined;
|
|
218
|
+
|
|
219
|
+
if (typeof plugin?.register !== "function") {
|
|
220
|
+
console.error(JSON.stringify({ error: "connector 未导出 default.register" }));
|
|
221
|
+
process.exit(1);
|
|
222
|
+
}
|
|
223
|
+
plugin.register({
|
|
224
|
+
runtime,
|
|
225
|
+
registerChannel: () => {},
|
|
226
|
+
registerGatewayMethod: () => {},
|
|
227
|
+
});
|
|
228
|
+
console.error("[dingtalk-stdio-bridge] 插件与 connector 匹配: 已调用 plugin.register(api)");
|
|
229
|
+
|
|
230
|
+
const listAccountIds = dingtalkPlugin?.config?.listAccountIds;
|
|
231
|
+
const resolveAccount = dingtalkPlugin?.config?.resolveAccount;
|
|
232
|
+
const isConfigured = dingtalkPlugin?.config?.isConfigured;
|
|
233
|
+
const startAccount = dingtalkPlugin?.gateway?.startAccount;
|
|
234
|
+
|
|
235
|
+
if (!startAccount) {
|
|
236
|
+
console.error(JSON.stringify({ error: "connector 未注册 gateway.startAccount(请确认 @dingtalk-real-ai/dingtalk-connector 导出 dingtalkPlugin)" }));
|
|
237
|
+
process.exit(1);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
const accountIds = listAccountIds ? listAccountIds(cfg) : ["__default__"];
|
|
241
|
+
console.error("[dingtalk-stdio-bridge] 插件与 connector 匹配: listAccountIds(cfg) =>", accountIds);
|
|
242
|
+
if (accountIds.length === 0) {
|
|
243
|
+
console.error(JSON.stringify({ error: "无可用钉钉账号,请检查 syim.json channels.dingtalk-connector 或环境变量" }));
|
|
244
|
+
process.exit(1);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
const log = {
|
|
248
|
+
info: (msg: string) => console.error("[DingTalk]", msg),
|
|
249
|
+
warn: (msg: string) => console.warn("[DingTalk]", msg),
|
|
250
|
+
error: (msg: string) => console.error("[DingTalk]", msg),
|
|
251
|
+
};
|
|
252
|
+
|
|
253
|
+
type ResolvedAccount = { accountId: string; config: Record<string, unknown>; enabled: boolean };
|
|
254
|
+
const cfgChannels = (cfg as Record<string, unknown>).channels as Record<string, unknown> | undefined;
|
|
255
|
+
const defaultAccount: ResolvedAccount = {
|
|
256
|
+
accountId: "__default__",
|
|
257
|
+
config: (cfgChannels?.[CHANNEL_KEY] as Record<string, unknown>) ?? {},
|
|
258
|
+
enabled: true,
|
|
259
|
+
};
|
|
260
|
+
|
|
261
|
+
let startedCount = 0;
|
|
262
|
+
for (const accountId of accountIds) {
|
|
263
|
+
const account: ResolvedAccount = (resolveAccount ? resolveAccount(cfg, accountId) : defaultAccount) as ResolvedAccount;
|
|
264
|
+
console.error("[dingtalk-stdio-bridge] 插件与 connector 匹配: resolveAccount(cfg,", accountId, ") =>", { accountId: account?.accountId, enabled: account?.enabled, configured: account ? isConfigured?.(account) : false });
|
|
265
|
+
if (!account?.enabled) {
|
|
266
|
+
log.info(`[${accountId}] 账号已禁用,跳过`);
|
|
267
|
+
continue;
|
|
268
|
+
}
|
|
269
|
+
if (isConfigured && !isConfigured(account)) {
|
|
270
|
+
log.warn(`[${accountId}] 账号未配置 clientId/clientSecret,跳过`);
|
|
271
|
+
continue;
|
|
272
|
+
}
|
|
273
|
+
// 插件 resolveAccount 返回的 config 即 cfg.channels[CHANNEL_KEY] 的引用(或合并后的对象),上面已写入 gatewayBaseUrl;
|
|
274
|
+
// 再在 account.config 上就地写入一次,确保插件 startAccount 内 const config = account.config 能拿到
|
|
275
|
+
const accConfig = (account as { config?: Record<string, unknown> }).config;
|
|
276
|
+
if (accConfig && typeof accConfig === "object") {
|
|
277
|
+
(accConfig as Record<string, unknown>).gatewayBaseUrl = gatewayBaseUrl;
|
|
278
|
+
if (gatewayToken) (accConfig as Record<string, unknown>).gatewayToken = gatewayToken;
|
|
279
|
+
}
|
|
280
|
+
console.error(
|
|
281
|
+
"[dingtalk-stdio-bridge] 调用 startAccount 前校验:",
|
|
282
|
+
"accountId=",
|
|
283
|
+
account.accountId,
|
|
284
|
+
"gatewayBaseUrl=",
|
|
285
|
+
(account.config as any)?.gatewayBaseUrl,
|
|
286
|
+
"gatewayToken=",
|
|
287
|
+
(account.config as any)?.gatewayToken ? "<present>" : "<absent>"
|
|
288
|
+
);
|
|
289
|
+
console.error("[dingtalk-stdio-bridge] 插件与 connector 匹配: 调用 gateway.startAccount 启动 accountId =", account.accountId);
|
|
290
|
+
const abort = new AbortController();
|
|
291
|
+
startAccount({
|
|
292
|
+
cfg,
|
|
293
|
+
accountId: account.accountId,
|
|
294
|
+
account,
|
|
295
|
+
abortSignal: abort.signal,
|
|
296
|
+
log,
|
|
297
|
+
}).catch((err: Error) => {
|
|
298
|
+
log.error(`[${account.accountId}] 启动失败: ${err.message}`);
|
|
299
|
+
});
|
|
300
|
+
startedCount++;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
console.error("[dingtalk-stdio-bridge] 钉钉 connector 已启动(与 start:connector 同一插件),账号数:", startedCount, ",Gateway 直连 gatewayBaseUrl");
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
main().catch((err) => {
|
|
307
|
+
console.error("[dingtalk-stdio-bridge]", err);
|
|
308
|
+
process.exit(1);
|
|
309
|
+
});
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 钉钉 Stream 收消息 + im-agent-hub 处理 + 钉钉 OpenAPI 回发(完整收发流程)
|
|
3
|
+
*
|
|
4
|
+
* 与官方 dingtalk-openclaw-connector 相同的收消息方式(dingtalk-stream),
|
|
5
|
+
* 收到后转成 InboundMessage 交给 hub.handleInbound,回复由 createDingTalkAdapter 发出。
|
|
6
|
+
*
|
|
7
|
+
* 使用方式:
|
|
8
|
+
* # 必填:钉钉应用
|
|
9
|
+
* DINGTALK_CLIENT_ID=xxx DINGTALK_CLIENT_SECRET=yyy pnpm run start:dingtalk
|
|
10
|
+
*
|
|
11
|
+
* # 若设置 IFLOW_API_KEY,单聊/群聊回复将使用 iflow 接口;不设则使用内置 echo 回复
|
|
12
|
+
* IFLOW_API_KEY=sk-xxx DINGTALK_CLIENT_ID=xxx DINGTALK_CLIENT_SECRET=yyy pnpm run start:dingtalk
|
|
13
|
+
*
|
|
14
|
+
* 环境变量:
|
|
15
|
+
* DINGTALK_CLIENT_ID 钉钉应用 AppKey(必填)
|
|
16
|
+
* DINGTALK_CLIENT_SECRET 钉钉应用 AppSecret(必填)
|
|
17
|
+
* IFLOW_API_KEY 可选,设置后使用 iflow 接口回答(可直接在钉钉里和机器人对话)
|
|
18
|
+
* IFLOW_BASE_URL 可选,iflow 接口地址,默认 http://92.113.117.22:10068/proxy/iflow
|
|
19
|
+
* DEBUG 设为 1 可开启 dingtalk-stream 调试
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import { DWClient, TOPIC_ROBOT } from "dingtalk-stream";
|
|
23
|
+
import {
|
|
24
|
+
createHub,
|
|
25
|
+
createDingTalkAdapter,
|
|
26
|
+
createRuleBasedRouter,
|
|
27
|
+
createInMemorySessionStore,
|
|
28
|
+
createLangChainLikeAgent,
|
|
29
|
+
createIflowAgent,
|
|
30
|
+
type InboundMessage,
|
|
31
|
+
} from "../src/index.js";
|
|
32
|
+
|
|
33
|
+
const clientId = process.env.DINGTALK_CLIENT_ID;
|
|
34
|
+
const clientSecret = process.env.DINGTALK_CLIENT_SECRET;
|
|
35
|
+
|
|
36
|
+
if (!clientId || !clientSecret) {
|
|
37
|
+
console.error("请设置环境变量: DINGTALK_CLIENT_ID, DINGTALK_CLIENT_SECRET");
|
|
38
|
+
process.exit(1);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const config = { clientId, clientSecret };
|
|
42
|
+
|
|
43
|
+
// 从钉钉 Stream 回调 data 中提取文本(与 connector extractMessageContent 对齐)
|
|
44
|
+
function extractText(data: Record<string, unknown>): string {
|
|
45
|
+
const msgtype = (data.msgtype as string) || "text";
|
|
46
|
+
switch (msgtype) {
|
|
47
|
+
case "text":
|
|
48
|
+
return ((data.text as Record<string, unknown>)?.content as string)?.trim() ?? "";
|
|
49
|
+
case "richText": {
|
|
50
|
+
const parts = (data.content as Record<string, unknown>)?.richText as Array<{ text?: string }> | undefined;
|
|
51
|
+
if (!Array.isArray(parts)) return "[富文本]";
|
|
52
|
+
return parts.map((p) => p?.text ?? "").join("") || "[富文本]";
|
|
53
|
+
}
|
|
54
|
+
case "picture":
|
|
55
|
+
return "[图片]";
|
|
56
|
+
case "audio":
|
|
57
|
+
return ((data.content as Record<string, unknown>)?.recognition as string) ?? "[语音]";
|
|
58
|
+
case "file": {
|
|
59
|
+
const fileName = (data.content as Record<string, unknown>)?.fileName as string;
|
|
60
|
+
return `[文件: ${fileName ?? "文件"}]`;
|
|
61
|
+
}
|
|
62
|
+
default:
|
|
63
|
+
return ((data.text as Record<string, unknown>)?.content as string)?.trim() ?? `[${msgtype}]`;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// 去重:避免同一消息重复处理
|
|
68
|
+
const processedIds = new Set<string>();
|
|
69
|
+
const TTL = 5 * 60 * 1000;
|
|
70
|
+
const cleanup = () => {
|
|
71
|
+
if (processedIds.size < 100) return;
|
|
72
|
+
const now = Date.now();
|
|
73
|
+
for (const id of processedIds) {
|
|
74
|
+
processedIds.delete(id);
|
|
75
|
+
break;
|
|
76
|
+
}
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
// 设置 IFLOW_API_KEY 时使用 iflow 接口回答,否则使用内置 echo
|
|
80
|
+
const useIflow = Boolean(process.env.IFLOW_API_KEY);
|
|
81
|
+
const mainAgent = useIflow
|
|
82
|
+
? createIflowAgent({
|
|
83
|
+
id: "main",
|
|
84
|
+
systemPrompt: "你是助手。回复简洁友好。",
|
|
85
|
+
historyLimit: 20,
|
|
86
|
+
stream: true, // 流式返回,钉钉 adapter 使用 AI Card 打字机效果
|
|
87
|
+
})
|
|
88
|
+
: createLangChainLikeAgent({
|
|
89
|
+
id: "main",
|
|
90
|
+
systemPrompt: "你是助手。回复简洁。",
|
|
91
|
+
historyLimit: 20,
|
|
92
|
+
runnable: async ({ messages }) => {
|
|
93
|
+
const last = messages[messages.length - 1];
|
|
94
|
+
return { content: `收到:${last?.content ?? ""}` };
|
|
95
|
+
},
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
const hub = createHub({
|
|
99
|
+
router: createRuleBasedRouter({ defaultAgentId: "main" }),
|
|
100
|
+
agents: [mainAgent],
|
|
101
|
+
adapters: [createDingTalkAdapter({ config })],
|
|
102
|
+
sessions: createInMemorySessionStore(),
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
const client = new DWClient({
|
|
106
|
+
clientId: config.clientId,
|
|
107
|
+
clientSecret: config.clientSecret,
|
|
108
|
+
debug: process.env.DEBUG === "1",
|
|
109
|
+
autoReconnect: true,
|
|
110
|
+
keepAlive: true,
|
|
111
|
+
// dingtalk-stream 的 DWClient 构造函数参数类型可能与声明不完全一致,用 any 避免 TS 报错
|
|
112
|
+
} as any);
|
|
113
|
+
|
|
114
|
+
client.registerCallbackListener(TOPIC_ROBOT, async (res: { headers?: { messageId?: string }; data?: string }) => {
|
|
115
|
+
const messageId = res.headers?.messageId;
|
|
116
|
+
if (messageId) {
|
|
117
|
+
client.socketCallBackResponse(messageId, { success: true });
|
|
118
|
+
if (processedIds.has(messageId)) {
|
|
119
|
+
console.warn("[DingTalk] 重复消息已跳过:", messageId);
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
processedIds.add(messageId);
|
|
123
|
+
cleanup();
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
let data: Record<string, unknown>;
|
|
127
|
+
try {
|
|
128
|
+
data = JSON.parse(res.data ?? "{}") as Record<string, unknown>;
|
|
129
|
+
} catch {
|
|
130
|
+
console.error("[DingTalk] 解析 data 失败");
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const text = extractText(data);
|
|
135
|
+
const conversationType = data.conversationType as string | undefined;
|
|
136
|
+
const isDirect = conversationType === "1";
|
|
137
|
+
const senderId = (data.senderStaffId ?? data.senderId) as string | undefined;
|
|
138
|
+
const senderNick = (data.senderNick as string) ?? "Unknown";
|
|
139
|
+
const conversationId = data.conversationId as string | undefined;
|
|
140
|
+
|
|
141
|
+
if (!senderId) {
|
|
142
|
+
console.warn("[DingTalk] 缺少 senderStaffId/senderId");
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const input: InboundMessage = {
|
|
147
|
+
provider: "dingtalk",
|
|
148
|
+
accountId: "__default__",
|
|
149
|
+
peer: isDirect
|
|
150
|
+
? { kind: "direct", id: senderId }
|
|
151
|
+
: { kind: "group", id: conversationId ?? senderId },
|
|
152
|
+
sender: { id: senderId, name: senderNick },
|
|
153
|
+
messageId: messageId ?? `stream-${Date.now()}`,
|
|
154
|
+
timestamp: Date.now(),
|
|
155
|
+
text,
|
|
156
|
+
};
|
|
157
|
+
|
|
158
|
+
console.log("[DingTalk] 收到:", isDirect ? "单聊" : "群聊", senderNick, text.slice(0, 80));
|
|
159
|
+
try {
|
|
160
|
+
await hub.handleInbound(input);
|
|
161
|
+
} catch (err) {
|
|
162
|
+
console.error("[DingTalk] handleInbound 错误:", err);
|
|
163
|
+
}
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
client.connect().then(() => {
|
|
167
|
+
console.log("钉钉 Stream 已连接,等待消息…");
|
|
168
|
+
if (useIflow) {
|
|
169
|
+
console.log("当前使用 iflow 接口回复(已设置 IFLOW_API_KEY),可直接在钉钉单聊/群聊中与机器人对话。");
|
|
170
|
+
} else {
|
|
171
|
+
console.log("未设置 IFLOW_API_KEY,使用内置 echo 回复。若要用 iflow,请设置 IFLOW_API_KEY 后重启。");
|
|
172
|
+
}
|
|
173
|
+
});
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 飞书 webhook 示例:HTTP 服务开启飞书回调路由,收消息后经 Hub 由飞书适配器回发
|
|
3
|
+
*
|
|
4
|
+
* 使用方式:
|
|
5
|
+
* 1. 在飞书开放平台创建应用,开通「接收消息」等权限,并订阅 im.message.receive_v1
|
|
6
|
+
* 2. 配置「请求地址」为:https://你的域名/webhook/feishu(需公网可访问)
|
|
7
|
+
* 3. 本地或服务器启动本脚本(若本地需用 ngrok 等暴露 /webhook/feishu)
|
|
8
|
+
*
|
|
9
|
+
* 环境变量:
|
|
10
|
+
* PORT HTTP 端口,默认 3100
|
|
11
|
+
* FEISHU_APP_ID 飞书应用 App ID(发消息用)
|
|
12
|
+
* FEISHU_APP_SECRET 飞书应用 App Secret(发消息用)
|
|
13
|
+
*
|
|
14
|
+
* 请求地址校验:使用 parseFeishuWebhookBodyWithVerification 作为 parseBody,
|
|
15
|
+
* 飞书配置 URL 时发的 type=url_verification 会原样返回 challenge 通过校验。
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import {
|
|
19
|
+
createHub,
|
|
20
|
+
createFeishuAdapter,
|
|
21
|
+
createRuleBasedRouter,
|
|
22
|
+
createInMemorySessionStore,
|
|
23
|
+
createLangChainLikeAgent,
|
|
24
|
+
parseFeishuWebhookBodyWithVerification,
|
|
25
|
+
startInboundServer,
|
|
26
|
+
} from "../src/index.js";
|
|
27
|
+
|
|
28
|
+
const PORT = Number(process.env.PORT) || 3100;
|
|
29
|
+
const feishuAppId = process.env.FEISHU_APP_ID;
|
|
30
|
+
const feishuAppSecret = process.env.FEISHU_APP_SECRET;
|
|
31
|
+
|
|
32
|
+
if (!feishuAppId || !feishuAppSecret) {
|
|
33
|
+
console.error("请设置环境变量: FEISHU_APP_ID, FEISHU_APP_SECRET(用于回发消息)");
|
|
34
|
+
process.exit(1);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const feishuCfg = { app_id: feishuAppId, app_secret: feishuAppSecret };
|
|
38
|
+
|
|
39
|
+
const echoAgent = createLangChainLikeAgent({
|
|
40
|
+
id: "main",
|
|
41
|
+
systemPrompt: "你是助手。回复简洁。",
|
|
42
|
+
historyLimit: 20,
|
|
43
|
+
runnable: async ({ messages }) => {
|
|
44
|
+
const last = messages[messages.length - 1];
|
|
45
|
+
return { content: `收到:${last?.content ?? ""}` };
|
|
46
|
+
},
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
const hub = createHub({
|
|
50
|
+
router: createRuleBasedRouter({ defaultAgentId: "main" }),
|
|
51
|
+
agents: [echoAgent],
|
|
52
|
+
adapters: [createFeishuAdapter({ cfg: feishuCfg })],
|
|
53
|
+
sessions: createInMemorySessionStore(),
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
startInboundServer({
|
|
57
|
+
hub,
|
|
58
|
+
port: PORT,
|
|
59
|
+
webhookRoutes: [
|
|
60
|
+
{
|
|
61
|
+
path: "/webhook/feishu",
|
|
62
|
+
parseBody: parseFeishuWebhookBodyWithVerification,
|
|
63
|
+
},
|
|
64
|
+
],
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
console.log(`[im-agent-hub] 已启动 http://localhost:${PORT}`);
|
|
68
|
+
console.log(" GET /health -> 健康检查");
|
|
69
|
+
console.log(" POST /inbound -> 通用 InboundMessage JSON");
|
|
70
|
+
console.log(" POST /webhook/feishu -> 飞书事件回调(含 URL 校验)");
|
|
71
|
+
console.log("请将飞书「请求地址」配置为: https://你的域名/webhook/feishu");
|