hyacinth-ai 0.9.20 → 0.9.21
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/dist/channels/dispatcher.d.ts +25 -0
- package/dist/channels/dispatcher.js +96 -0
- package/dist/channels/interface.d.ts +17 -0
- package/dist/channels/plugins/feishu/feishu-channel.d.ts +3 -1
- package/dist/channels/plugins/feishu/feishu-channel.js +104 -1
- package/dist/channels/plugins/feishu/feishu-send.d.ts +7 -0
- package/dist/channels/plugins/feishu/feishu-send.js +23 -0
- package/dist/gateway/tui.js +6 -0
- package/package.json +1 -1
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type { Tool } from '../tools/interface.js';
|
|
2
|
+
import type { ChannelManager } from './manager.js';
|
|
3
|
+
/**
|
|
4
|
+
* MessageDispatcher — 统一分发层。
|
|
5
|
+
* 查目标渠道 → 调其 send() → 返回结果。
|
|
6
|
+
*/
|
|
7
|
+
export declare class MessageDispatcher {
|
|
8
|
+
private channelManager;
|
|
9
|
+
constructor(channelManager: ChannelManager);
|
|
10
|
+
send(args: {
|
|
11
|
+
channel: string;
|
|
12
|
+
to: string;
|
|
13
|
+
targetType?: 'user' | 'chat';
|
|
14
|
+
text?: string;
|
|
15
|
+
images?: Array<{
|
|
16
|
+
data: string;
|
|
17
|
+
media_type: string;
|
|
18
|
+
}>;
|
|
19
|
+
}): Promise<string>;
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* send_channel_message 工具——暴露给 Agent 的跨渠道发送能力。
|
|
23
|
+
*/
|
|
24
|
+
export declare function createSendChannelMessageTool(dispatcher: MessageDispatcher): Tool;
|
|
25
|
+
//# sourceMappingURL=dispatcher.d.ts.map
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
// ============================================================
|
|
2
|
+
// MessageDispatcher — 跨渠道消息分发层
|
|
3
|
+
// ============================================================
|
|
4
|
+
//
|
|
5
|
+
// 允许 Agent 从任意会话借用其他渠道的发送能力。
|
|
6
|
+
// 消息是一次性的——不创建 session、不写 conversation、不影响任何渠道的对话状态。
|
|
7
|
+
// 被借用的渠道对"谁借了道"完全无感。
|
|
8
|
+
// ============================================================
|
|
9
|
+
/**
|
|
10
|
+
* MessageDispatcher — 统一分发层。
|
|
11
|
+
* 查目标渠道 → 调其 send() → 返回结果。
|
|
12
|
+
*/
|
|
13
|
+
export class MessageDispatcher {
|
|
14
|
+
channelManager;
|
|
15
|
+
constructor(channelManager) {
|
|
16
|
+
this.channelManager = channelManager;
|
|
17
|
+
}
|
|
18
|
+
async send(args) {
|
|
19
|
+
const state = this.channelManager.get(args.channel);
|
|
20
|
+
if (!state || state.status !== 'active') {
|
|
21
|
+
return `渠道 "${args.channel}" 未连接或未启动。可用渠道: ${this.channelManager.getStatusSummary().map(s => s.id).join(', ') || '无'}`;
|
|
22
|
+
}
|
|
23
|
+
const handler = state.handler;
|
|
24
|
+
if (!handler.send) {
|
|
25
|
+
return `渠道 "${args.channel}" (${handler.name}) 不支持主动发送。`;
|
|
26
|
+
}
|
|
27
|
+
const target = {
|
|
28
|
+
type: args.targetType ?? 'user',
|
|
29
|
+
id: args.to,
|
|
30
|
+
};
|
|
31
|
+
try {
|
|
32
|
+
const result = await handler.send(target, {
|
|
33
|
+
content: args.text ?? '',
|
|
34
|
+
images: args.images,
|
|
35
|
+
});
|
|
36
|
+
return result;
|
|
37
|
+
}
|
|
38
|
+
catch (err) {
|
|
39
|
+
return `发送失败: ${err instanceof Error ? err.message : String(err)}`;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* send_channel_message 工具——暴露给 Agent 的跨渠道发送能力。
|
|
45
|
+
*/
|
|
46
|
+
export function createSendChannelMessageTool(dispatcher) {
|
|
47
|
+
return {
|
|
48
|
+
name: 'send_channel_message',
|
|
49
|
+
description: '借用指定渠道发送消息到目标用户或群聊。纯借用渠道能力——不创建会话、不写入对话记录、不影响其他渠道。' +
|
|
50
|
+
'适用于从当前渠道(如 TUI)通过飞书/微信等渠道向用户发送通知、图片或文件。',
|
|
51
|
+
inputSchema: {
|
|
52
|
+
type: 'object',
|
|
53
|
+
properties: {
|
|
54
|
+
channel: {
|
|
55
|
+
type: 'string',
|
|
56
|
+
description: '目标渠道 ID。可用 channel_info 查看已连接渠道。如 "feishu"、"clawbot"。',
|
|
57
|
+
},
|
|
58
|
+
to: {
|
|
59
|
+
type: 'string',
|
|
60
|
+
description: '目标用户 ID 或群聊 ID。飞书为 open_id 或 chat_id,微信为 wxid。',
|
|
61
|
+
},
|
|
62
|
+
target_type: {
|
|
63
|
+
type: 'string',
|
|
64
|
+
enum: ['user', 'chat'],
|
|
65
|
+
description: '目标类型。user=私聊,chat=群聊。默认 user。',
|
|
66
|
+
},
|
|
67
|
+
text: {
|
|
68
|
+
type: 'string',
|
|
69
|
+
description: '要发送的文本内容。',
|
|
70
|
+
},
|
|
71
|
+
images: {
|
|
72
|
+
type: 'array',
|
|
73
|
+
items: {
|
|
74
|
+
type: 'object',
|
|
75
|
+
properties: {
|
|
76
|
+
data: { type: 'string', description: '图片的 base64 数据' },
|
|
77
|
+
media_type: { type: 'string', description: 'MIME 类型,如 image/png' },
|
|
78
|
+
},
|
|
79
|
+
},
|
|
80
|
+
description: '要发送的图片列表(base64 + MIME 类型)。',
|
|
81
|
+
},
|
|
82
|
+
},
|
|
83
|
+
required: ['channel', 'to'],
|
|
84
|
+
},
|
|
85
|
+
async execute(args) {
|
|
86
|
+
return dispatcher.send({
|
|
87
|
+
channel: args.channel,
|
|
88
|
+
to: args.to,
|
|
89
|
+
targetType: args.target_type ?? 'user',
|
|
90
|
+
text: args.text || undefined,
|
|
91
|
+
images: args.images,
|
|
92
|
+
});
|
|
93
|
+
},
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
//# sourceMappingURL=dispatcher.js.map
|
|
@@ -45,6 +45,13 @@ export interface ChannelReply {
|
|
|
45
45
|
}>;
|
|
46
46
|
metadata?: Record<string, unknown>;
|
|
47
47
|
}
|
|
48
|
+
/** 主动发送的目标(跨渠道借用能力时指定接收方) */
|
|
49
|
+
export interface ChannelTarget {
|
|
50
|
+
/** 目标类型 */
|
|
51
|
+
type: 'user' | 'chat';
|
|
52
|
+
/** 用户 ID 或群聊 ID */
|
|
53
|
+
id: string;
|
|
54
|
+
}
|
|
48
55
|
/** 输出处理器(渠道消息收集用) */
|
|
49
56
|
export interface ChannelOutputHandler {
|
|
50
57
|
onText?(content: string): void;
|
|
@@ -137,6 +144,16 @@ export interface ChannelHandler {
|
|
|
137
144
|
* 获取渠道状态
|
|
138
145
|
*/
|
|
139
146
|
getStatus(): ChannelStatus;
|
|
147
|
+
/**
|
|
148
|
+
* 主动发送消息到渠道(跨 session,不关联任何 AgentLoop、不落盘)。
|
|
149
|
+
* 用于从其他渠道借用本渠道的发送能力(如 TUI 中的 Agent 通过飞书发消息)。
|
|
150
|
+
* 未实现 = 该渠道不支持被外部借用。
|
|
151
|
+
*
|
|
152
|
+
* @param target 目标用户或群聊
|
|
153
|
+
* @param content 消息内容
|
|
154
|
+
* @returns 发送结果描述
|
|
155
|
+
*/
|
|
156
|
+
send?(target: ChannelTarget, content: ChannelReply): Promise<string>;
|
|
140
157
|
}
|
|
141
158
|
export type ChannelStatus = 'registered' | 'starting' | 'active' | 'stopped' | 'error';
|
|
142
159
|
export interface ChannelState {
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { ChannelHandler, ChannelEvent, ChannelReply, ChannelConfig, ChannelStatus, ChannelMessageEvent, AgentFactory, ReplyFn } from '../../interface.js';
|
|
1
|
+
import type { ChannelHandler, ChannelEvent, ChannelReply, ChannelTarget, ChannelConfig, ChannelStatus, ChannelMessageEvent, AgentFactory, ReplyFn } from '../../interface.js';
|
|
2
2
|
export declare class FeishuChannel implements ChannelHandler {
|
|
3
3
|
readonly id = "feishu";
|
|
4
4
|
readonly name = "Feishu (\u98DE\u4E66)";
|
|
@@ -32,6 +32,8 @@ export declare class FeishuChannel implements ChannelHandler {
|
|
|
32
32
|
onEvent(handler: (event: ChannelEvent) => Promise<void>): void;
|
|
33
33
|
reply(sessionId: string, reply: ChannelReply): Promise<void>;
|
|
34
34
|
getStatus(): ChannelStatus;
|
|
35
|
+
send(target: ChannelTarget, content: ChannelReply): Promise<string>;
|
|
36
|
+
private uploadImage;
|
|
35
37
|
/** 获取 tenant access token(缓存,提前 60s 刷新,token 有效期 2h) */
|
|
36
38
|
private getTenantAccessToken;
|
|
37
39
|
/**
|
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
import { resolveFeishuConfig, validateFeishuConfig, } from './feishu-config.js';
|
|
15
15
|
import { FeishuTransport } from './feishu-transport.js';
|
|
16
16
|
import { parseFeishuMessageEvent, FeishuDedupeStore, checkDmAccess, checkGroupAccess, } from './feishu-event.js';
|
|
17
|
-
import { sendText, sendCard, getSenderInfo } from './feishu-send.js';
|
|
17
|
+
import { sendText, sendCard, sendImage, getSenderInfo } from './feishu-send.js';
|
|
18
18
|
import { ChannelSessionPool, createCollectHandler } from './feishu-session.js';
|
|
19
19
|
import { FeishuMessageQueue } from './feishu-message-queue.js';
|
|
20
20
|
import { generateSessionId } from '../../../memory/session.js';
|
|
@@ -176,6 +176,109 @@ export class FeishuChannel {
|
|
|
176
176
|
getStatus() {
|
|
177
177
|
return this.status;
|
|
178
178
|
}
|
|
179
|
+
// ================================================================
|
|
180
|
+
// send() — 跨渠道借用能力入口
|
|
181
|
+
// ================================================================
|
|
182
|
+
//
|
|
183
|
+
// 设计意图:
|
|
184
|
+
// 每个渠道有自己独特的发送能力(飞书能发卡片和图片、微信能发文件、
|
|
185
|
+
// TUI 只能显示文本)。send() 将这些能力暴露给外部,使得其他渠道
|
|
186
|
+
// 的 Agent 可以借用本渠道的能力发送消息。
|
|
187
|
+
//
|
|
188
|
+
// 行为保证("纯借用"语义):
|
|
189
|
+
// - 不创建 session —— 消息是一次性的,没有对应的 AgentLoop
|
|
190
|
+
// - 不写 conversation.jsonl —— 不污染任何渠道的对话历史
|
|
191
|
+
// - 不影响 ChannelHandler 内部状态 —— sessionMap、消息队列等完全不变
|
|
192
|
+
// - 被借用方对"谁借的"完全无感 —— 不需要知道调用方是哪个渠道
|
|
193
|
+
//
|
|
194
|
+
// 调用链:
|
|
195
|
+
// Agent → send_channel_message 工具 → MessageDispatcher
|
|
196
|
+
// → ChannelManager.get('feishu') → FeishuChannel.send()
|
|
197
|
+
//
|
|
198
|
+
// 支持的消息类型(按优先级):
|
|
199
|
+
// 1. 卡片消息(content.metadata.card 存在时)—— 飞书交互式卡片
|
|
200
|
+
// 2. 文本消息(默认)—— Post 格式,支持 Markdown 子集
|
|
201
|
+
// 3. 图片消息(content.images 存在时)—— 先上传获取 image_key,再发送
|
|
202
|
+
// ↑ 图片在文本/卡片之后独立发送,失败不影响前面的消息
|
|
203
|
+
//
|
|
204
|
+
// 注意:
|
|
205
|
+
// - 图片上传需要 tenant_access_token(通过 getTenantAccessToken 获取)
|
|
206
|
+
// - 每条图片独立上传 + 发送,某张失败不阻塞其他图片
|
|
207
|
+
// - 图片 base64 直接传给飞书 API,不在本地落盘
|
|
208
|
+
// ================================================================
|
|
209
|
+
async send(target, content) {
|
|
210
|
+
if (this.status !== 'active')
|
|
211
|
+
return '飞书渠道未连接,无法发送';
|
|
212
|
+
const config = this.config;
|
|
213
|
+
// 飞书 SDK 的 receive_id_type 由 ID 前缀推断(ou_→open_id, oc_→chat_id)
|
|
214
|
+
// sendText/sendCard/sendImage 内部调用 resolveSendTarget 自动处理
|
|
215
|
+
const to = target.type === 'chat' ? `chat:${target.id}` : `user:${target.id}`;
|
|
216
|
+
try {
|
|
217
|
+
// ① 主消息:卡片优先,否则文本
|
|
218
|
+
if (content.metadata?.card) {
|
|
219
|
+
await sendCard(config, { to, card: content.metadata.card });
|
|
220
|
+
}
|
|
221
|
+
else {
|
|
222
|
+
await sendText(config, { to, text: content.content });
|
|
223
|
+
}
|
|
224
|
+
// ② 图片:在文本/卡片之后独立发送
|
|
225
|
+
// 飞书的图片消息和文本消息是两条独立的消息,不支持图文混排
|
|
226
|
+
if (content.images && content.images.length > 0) {
|
|
227
|
+
const token = await this.getTenantAccessToken();
|
|
228
|
+
if (token) {
|
|
229
|
+
for (const img of content.images) {
|
|
230
|
+
try {
|
|
231
|
+
// 飞书图片发送两步:上传拿 image_key → 调用 im.message.create 发送
|
|
232
|
+
const imageKey = await this.uploadImage(token, img.data);
|
|
233
|
+
await sendImage(config, { to, imageKey });
|
|
234
|
+
}
|
|
235
|
+
catch {
|
|
236
|
+
// 某张图片失败不阻塞其他图片和整体流程
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
return `已通过飞书发送到 ${to}`;
|
|
242
|
+
}
|
|
243
|
+
catch (err) {
|
|
244
|
+
return `飞书发送失败: ${err instanceof Error ? err.message : String(err)}`;
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
// ================================================================
|
|
248
|
+
// uploadImage() — 飞书图片上传
|
|
249
|
+
// ================================================================
|
|
250
|
+
//
|
|
251
|
+
// 飞书发送图片必须先上传到飞书服务器获取 image_key,
|
|
252
|
+
// 再用 image_key 调用 im.message.create(msg_type='image')发送。
|
|
253
|
+
//
|
|
254
|
+
// API: POST https://open.feishu.cn/open-apis/im/v1/images
|
|
255
|
+
// 参数: image_type='message'(消息用图,非头像),image=base64(不含 data:xxx;base64, 前缀)
|
|
256
|
+
// 返回: { code: 0, data: { image_key: 'img_xxx' } }
|
|
257
|
+
//
|
|
258
|
+
// 注意:
|
|
259
|
+
// - token 由调用方传入(来自 getTenantAccessToken 的缓存结果)
|
|
260
|
+
// - mediaType 参数保留供未来扩展(如格式校验),当前仅透传 base64
|
|
261
|
+
// - 飞书 image_key 有时效性(约 2 小时),不持久化缓存
|
|
262
|
+
// ================================================================
|
|
263
|
+
async uploadImage(token, base64Data, _mediaType) {
|
|
264
|
+
const domain = this.config?.domain ?? 'https://open.feishu.cn';
|
|
265
|
+
const resp = await fetch(`${domain}/open-apis/im/v1/images`, {
|
|
266
|
+
method: 'POST',
|
|
267
|
+
headers: {
|
|
268
|
+
'Authorization': `Bearer ${token}`,
|
|
269
|
+
'Content-Type': 'application/json',
|
|
270
|
+
},
|
|
271
|
+
body: JSON.stringify({
|
|
272
|
+
image_type: 'message',
|
|
273
|
+
image: base64Data,
|
|
274
|
+
}),
|
|
275
|
+
});
|
|
276
|
+
const json = await resp.json();
|
|
277
|
+
if (json.code !== 0 || !json.data?.image_key) {
|
|
278
|
+
throw new Error(`飞书图片上传失败: code=${json.code}`);
|
|
279
|
+
}
|
|
280
|
+
return json.data.image_key;
|
|
281
|
+
}
|
|
179
282
|
/** 获取 tenant access token(缓存,提前 60s 刷新,token 有效期 2h) */
|
|
180
283
|
async getTenantAccessToken() {
|
|
181
284
|
if (this.cachedToken && Date.now() < this.tokenExpiresAt - 60_000) {
|
|
@@ -41,4 +41,11 @@ export declare function getSenderInfo(config: FeishuChannelConfig, userId: strin
|
|
|
41
41
|
name?: string;
|
|
42
42
|
avatar?: string;
|
|
43
43
|
} | null>;
|
|
44
|
+
/**
|
|
45
|
+
* 发送图片消息(需先通过上传 API 获取 image_key)。
|
|
46
|
+
*/
|
|
47
|
+
export declare function sendImage(config: FeishuChannelConfig, params: {
|
|
48
|
+
to: string;
|
|
49
|
+
imageKey: string;
|
|
50
|
+
}): Promise<FeishuSendResult>;
|
|
44
51
|
//# sourceMappingURL=feishu-send.d.ts.map
|
|
@@ -229,4 +229,27 @@ export async function getSenderInfo(config, userId, userIdType = 'open_id') {
|
|
|
229
229
|
return null;
|
|
230
230
|
}
|
|
231
231
|
}
|
|
232
|
+
/**
|
|
233
|
+
* 发送图片消息(需先通过上传 API 获取 image_key)。
|
|
234
|
+
*/
|
|
235
|
+
export async function sendImage(config, params) {
|
|
236
|
+
const client = await createFeishuClient(config);
|
|
237
|
+
const { receiveId, receiveIdType } = resolveSendTarget(params.to);
|
|
238
|
+
const content = JSON.stringify({ image_key: params.imageKey });
|
|
239
|
+
const res = await client.im.message.create({
|
|
240
|
+
params: { receive_id_type: receiveIdType },
|
|
241
|
+
data: {
|
|
242
|
+
receive_id: receiveId,
|
|
243
|
+
content,
|
|
244
|
+
msg_type: 'image',
|
|
245
|
+
},
|
|
246
|
+
});
|
|
247
|
+
if (res.code !== 0) {
|
|
248
|
+
throw new Error(`飞书图片发送失败: ${res.msg || `code ${res.code}`}`);
|
|
249
|
+
}
|
|
250
|
+
return {
|
|
251
|
+
messageId: res.data?.message_id ?? '',
|
|
252
|
+
chatId: receiveId,
|
|
253
|
+
};
|
|
254
|
+
}
|
|
232
255
|
//# sourceMappingURL=feishu-send.js.map
|
package/dist/gateway/tui.js
CHANGED
|
@@ -970,6 +970,12 @@ export async function runTui(provider, sessionId, shouldContinue, maxTurns, maxC
|
|
|
970
970
|
}
|
|
971
971
|
// 启动所有渠道(每个渠道自行处理消息)
|
|
972
972
|
await channelManager.startAll(agentFactory);
|
|
973
|
+
// 注册跨渠道发送工具(TUI 模式)
|
|
974
|
+
if (agent) {
|
|
975
|
+
const { MessageDispatcher, createSendChannelMessageTool } = await import('../channels/dispatcher.js');
|
|
976
|
+
const dispatcher = new MessageDispatcher(channelManager);
|
|
977
|
+
agent.toolRegistry.register(createSendChannelMessageTool(dispatcher));
|
|
978
|
+
}
|
|
973
979
|
// ── ASCII Art loader ──────────────────────────────────────────────
|
|
974
980
|
async function loadAsciiArt(maxWidth = 54) {
|
|
975
981
|
const asciiDir = path.join(os.homedir(), '.agent', 'ascii');
|