@botcord/daemon 0.2.59 → 0.2.61
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/config.d.ts +4 -1
- package/dist/config.js +2 -2
- package/dist/cross-room.js +3 -1
- package/dist/daemon-config-map.js +6 -0
- package/dist/daemon.js +21 -1
- package/dist/gateway/channels/botcord.d.ts +7 -0
- package/dist/gateway/channels/botcord.js +3 -1
- package/dist/gateway/channels/feishu-registration.d.ts +35 -0
- package/dist/gateway/channels/feishu-registration.js +101 -0
- package/dist/gateway/channels/feishu.d.ts +16 -0
- package/dist/gateway/channels/feishu.js +459 -0
- package/dist/gateway/channels/index.d.ts +2 -0
- package/dist/gateway/channels/index.js +2 -0
- package/dist/gateway/channels/login-session.d.ts +9 -1
- package/dist/gateway/channels/login-session.js +1 -1
- package/dist/gateway/dispatcher.js +7 -3
- package/dist/gateway/policy-resolver.d.ts +10 -6
- package/dist/gateway/types.d.ts +2 -1
- package/dist/gateway-control.d.ts +8 -1
- package/dist/gateway-control.js +171 -18
- package/dist/provision.js +7 -1
- package/package.json +2 -1
- package/src/__tests__/cross-room.test.ts +2 -0
- package/src/__tests__/gateway-control.test.ts +84 -0
- package/src/__tests__/policy-updated-handler.test.ts +5 -7
- package/src/__tests__/third-party-gateway.test.ts +28 -0
- package/src/config.ts +6 -3
- package/src/cross-room.ts +3 -1
- package/src/daemon-config-map.ts +3 -0
- package/src/daemon.ts +24 -3
- package/src/gateway/__tests__/botcord-channel.test.ts +77 -0
- package/src/gateway/__tests__/dispatcher.test.ts +14 -4
- package/src/gateway/__tests__/feishu-channel.test.ts +306 -0
- package/src/gateway/channels/botcord.ts +10 -1
- package/src/gateway/channels/feishu-registration.ts +155 -0
- package/src/gateway/channels/feishu.ts +554 -0
- package/src/gateway/channels/index.ts +6 -0
- package/src/gateway/channels/login-session.ts +10 -2
- package/src/gateway/dispatcher.ts +7 -3
- package/src/gateway/policy-resolver.ts +19 -11
- package/src/gateway/types.ts +2 -1
- package/src/gateway-control.ts +188 -17
- package/src/provision.ts +13 -1
|
@@ -27,6 +27,9 @@ function makeClient(overrides: Partial<BotCordChannelClient> = {}): BotCordChann
|
|
|
27
27
|
sendMessage: vi
|
|
28
28
|
.fn()
|
|
29
29
|
.mockResolvedValue({ hub_msg_id: "m_provider", queued: true, status: "queued" }),
|
|
30
|
+
sendTypedMessage: vi
|
|
31
|
+
.fn()
|
|
32
|
+
.mockResolvedValue({ hub_msg_id: "m_provider_typed", queued: true, status: "queued" }),
|
|
30
33
|
getHubUrl: vi.fn().mockReturnValue("http://127.0.0.1:1"),
|
|
31
34
|
...overrides,
|
|
32
35
|
};
|
|
@@ -138,6 +141,34 @@ describe("createBotCordChannel — send()", () => {
|
|
|
138
141
|
expect(client.sendMessage).toHaveBeenCalledWith("rm_dm_1", "hey", {});
|
|
139
142
|
expect(result.providerMessageId).toBeNull();
|
|
140
143
|
});
|
|
144
|
+
|
|
145
|
+
it("sends runtime diagnostics as BotCord error envelopes", async () => {
|
|
146
|
+
const client = makeClient();
|
|
147
|
+
const channel = createBotCordChannel({
|
|
148
|
+
id: "botcord-main",
|
|
149
|
+
accountId: "ag_self",
|
|
150
|
+
agentId: "ag_self",
|
|
151
|
+
client,
|
|
152
|
+
});
|
|
153
|
+
const result = await channel.send({
|
|
154
|
+
message: {
|
|
155
|
+
channel: "botcord",
|
|
156
|
+
accountId: "ag_self",
|
|
157
|
+
conversationId: "rm_group_a",
|
|
158
|
+
threadId: "tp_42",
|
|
159
|
+
replyTo: "env_source",
|
|
160
|
+
type: "error",
|
|
161
|
+
text: "Runtime error: boom",
|
|
162
|
+
},
|
|
163
|
+
log: silentLog,
|
|
164
|
+
});
|
|
165
|
+
expect(client.sendTypedMessage).toHaveBeenCalledWith("rm_group_a", "error", "Runtime error: boom", {
|
|
166
|
+
topic: "tp_42",
|
|
167
|
+
replyTo: "env_source",
|
|
168
|
+
});
|
|
169
|
+
expect(client.sendMessage).not.toHaveBeenCalled();
|
|
170
|
+
expect(result.providerMessageId).toBe("m_provider_typed");
|
|
171
|
+
});
|
|
141
172
|
});
|
|
142
173
|
|
|
143
174
|
// ---------------------------------------------------------------------------
|
|
@@ -263,6 +294,52 @@ describe("createBotCordChannel — inbox normalization", () => {
|
|
|
263
294
|
}
|
|
264
295
|
});
|
|
265
296
|
|
|
297
|
+
it("acks error InboxMessages without dispatching them", async () => {
|
|
298
|
+
const server = await startAuthOkServer();
|
|
299
|
+
const errorMessage = makeInbox({
|
|
300
|
+
hub_msg_id: "m_error_1",
|
|
301
|
+
text: undefined,
|
|
302
|
+
envelope: {
|
|
303
|
+
type: "error",
|
|
304
|
+
from: "ag_peer",
|
|
305
|
+
payload: { error: { code: "agent_error", message: "Runtime error: boom" } },
|
|
306
|
+
} as InboxMessage["envelope"],
|
|
307
|
+
});
|
|
308
|
+
const client = makeClient({
|
|
309
|
+
pollInbox: vi.fn().mockResolvedValue({ messages: [errorMessage], count: 1, has_more: false }),
|
|
310
|
+
getHubUrl: vi.fn().mockReturnValue(server.url),
|
|
311
|
+
});
|
|
312
|
+
const channel = createBotCordChannel({
|
|
313
|
+
id: "botcord-main",
|
|
314
|
+
accountId: "ag_self",
|
|
315
|
+
agentId: "ag_self",
|
|
316
|
+
client,
|
|
317
|
+
hubBaseUrl: server.url,
|
|
318
|
+
});
|
|
319
|
+
const abort = new AbortController();
|
|
320
|
+
const emits: GatewayInboundEnvelope[] = [];
|
|
321
|
+
const startPromise = channel.start({
|
|
322
|
+
config: stubConfig,
|
|
323
|
+
accountId: "ag_self",
|
|
324
|
+
abortSignal: abort.signal,
|
|
325
|
+
log: silentLog,
|
|
326
|
+
emit: async (env) => {
|
|
327
|
+
emits.push(env);
|
|
328
|
+
},
|
|
329
|
+
setStatus: () => {},
|
|
330
|
+
});
|
|
331
|
+
try {
|
|
332
|
+
await vi.waitFor(() => {
|
|
333
|
+
expect(client.ackMessages).toHaveBeenCalledWith(["m_error_1"]);
|
|
334
|
+
});
|
|
335
|
+
expect(emits).toHaveLength(0);
|
|
336
|
+
} finally {
|
|
337
|
+
abort.abort();
|
|
338
|
+
await startPromise;
|
|
339
|
+
await server.close();
|
|
340
|
+
}
|
|
341
|
+
});
|
|
342
|
+
|
|
266
343
|
it("marks rm_dm_ and rm_oc_ rooms as direct; rm_oc_ also sets streamable + user-kind", async () => {
|
|
267
344
|
const { emits, server } = await startWithInbox([
|
|
268
345
|
makeInbox({
|
|
@@ -492,6 +492,7 @@ describe("Dispatcher", () => {
|
|
|
492
492
|
await dispatcher.handle(makeEnvelope({ id: "msg_error" }));
|
|
493
493
|
|
|
494
494
|
expect(channel.sends.length).toBe(1);
|
|
495
|
+
expect(channel.sends[0].message.type).toBe("error");
|
|
495
496
|
expect(channel.sends[0].message.text).toContain("Runtime error");
|
|
496
497
|
expect(channel.sends[0].message.text).toContain("missing openclawAgent");
|
|
497
498
|
});
|
|
@@ -1256,6 +1257,7 @@ describe("Dispatcher", () => {
|
|
|
1256
1257
|
|
|
1257
1258
|
await dispatcher.handle(makeEnvelope({ id: "m1" }));
|
|
1258
1259
|
expect(channel.sends.length).toBe(1);
|
|
1260
|
+
expect(channel.sends[0].message.type).toBe("error");
|
|
1259
1261
|
expect(channel.sends[0].message.text).toContain("Runtime error");
|
|
1260
1262
|
expect(channel.sends[0].message.text).toContain("boom");
|
|
1261
1263
|
expect(store.all().length).toBe(0);
|
|
@@ -1903,7 +1905,7 @@ describe("Dispatcher", () => {
|
|
|
1903
1905
|
expect(channel.sends.length).toBe(0);
|
|
1904
1906
|
});
|
|
1905
1907
|
|
|
1906
|
-
it("non-owner-chat room: timeout
|
|
1908
|
+
it("non-owner-chat room: timeout sends a diagnostic reply", async () => {
|
|
1907
1909
|
vi.useFakeTimers();
|
|
1908
1910
|
try {
|
|
1909
1911
|
const runtime = new FakeRuntime({ hang: true });
|
|
@@ -1920,13 +1922,17 @@ describe("Dispatcher", () => {
|
|
|
1920
1922
|
await vi.advanceTimersByTimeAsync(501);
|
|
1921
1923
|
await p;
|
|
1922
1924
|
expect(runtime.calls[0].signal.aborted).toBe(true);
|
|
1923
|
-
expect(channel.sends.length).toBe(
|
|
1925
|
+
expect(channel.sends.length).toBe(1);
|
|
1926
|
+
expect(channel.sends[0].message.type).toBe("error");
|
|
1927
|
+
expect(channel.sends[0].message.text).toMatch(/Runtime timeout/);
|
|
1928
|
+
expect(channel.sends[0].message.conversationId).toBe("rm_g_other");
|
|
1929
|
+
expect(channel.sends[0].message.replyTo).toBe("m_to");
|
|
1924
1930
|
} finally {
|
|
1925
1931
|
vi.useRealTimers();
|
|
1926
1932
|
}
|
|
1927
1933
|
});
|
|
1928
1934
|
|
|
1929
|
-
it("non-owner-chat room: runtime error
|
|
1935
|
+
it("non-owner-chat room: runtime error sends a diagnostic reply", async () => {
|
|
1930
1936
|
const runtime = new FakeRuntime({ throwError: "boom" });
|
|
1931
1937
|
const { dispatcher, channel } = await scaffold({
|
|
1932
1938
|
runtimeFactory: () => runtime,
|
|
@@ -1937,7 +1943,11 @@ describe("Dispatcher", () => {
|
|
|
1937
1943
|
conversation: { id: "rm_g_other", kind: "group" },
|
|
1938
1944
|
}),
|
|
1939
1945
|
);
|
|
1940
|
-
expect(channel.sends.length).toBe(
|
|
1946
|
+
expect(channel.sends.length).toBe(1);
|
|
1947
|
+
expect(channel.sends[0].message.type).toBe("error");
|
|
1948
|
+
expect(channel.sends[0].message.text).toContain("Runtime error: boom");
|
|
1949
|
+
expect(channel.sends[0].message.conversationId).toBe("rm_g_other");
|
|
1950
|
+
expect(channel.sends[0].message.replyTo).toBe("m_err");
|
|
1941
1951
|
});
|
|
1942
1952
|
|
|
1943
1953
|
// ─────────────────────────────────────────────────────────────────────
|
|
@@ -0,0 +1,306 @@
|
|
|
1
|
+
import { mkdtempSync, rmSync } from "node:fs";
|
|
2
|
+
import { tmpdir } from "node:os";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
5
|
+
import { createFeishuChannel } from "../channels/feishu.js";
|
|
6
|
+
import type {
|
|
7
|
+
ChannelStartContext,
|
|
8
|
+
GatewayInboundEnvelope,
|
|
9
|
+
GatewayLogger,
|
|
10
|
+
} from "../types.js";
|
|
11
|
+
|
|
12
|
+
const larkMock = vi.hoisted(() => ({
|
|
13
|
+
requests: [] as unknown[],
|
|
14
|
+
responses: [] as unknown[],
|
|
15
|
+
handlers: {} as Record<string, (data: unknown) => unknown>,
|
|
16
|
+
wsStartError: null as Error | null,
|
|
17
|
+
wsClosed: false,
|
|
18
|
+
}));
|
|
19
|
+
|
|
20
|
+
vi.mock("@larksuiteoapi/node-sdk", () => ({
|
|
21
|
+
AppType: { SelfBuild: "SelfBuild" },
|
|
22
|
+
Domain: { Feishu: "feishu", Lark: "lark" },
|
|
23
|
+
LoggerLevel: { info: "info" },
|
|
24
|
+
Client: vi.fn().mockImplementation(function Client() {
|
|
25
|
+
return {
|
|
26
|
+
request: vi.fn(async (args: unknown) => {
|
|
27
|
+
larkMock.requests.push(args);
|
|
28
|
+
const next = larkMock.responses.shift();
|
|
29
|
+
if (next instanceof Error) throw next;
|
|
30
|
+
return next ?? { code: 0, data: {} };
|
|
31
|
+
}),
|
|
32
|
+
};
|
|
33
|
+
}),
|
|
34
|
+
EventDispatcher: vi.fn().mockImplementation(function EventDispatcher() {
|
|
35
|
+
return {
|
|
36
|
+
register: vi.fn((handlers: Record<string, (data: unknown) => unknown>) => {
|
|
37
|
+
Object.assign(larkMock.handlers, handlers);
|
|
38
|
+
}),
|
|
39
|
+
};
|
|
40
|
+
}),
|
|
41
|
+
WSClient: vi.fn().mockImplementation(function WSClient() {
|
|
42
|
+
return {
|
|
43
|
+
start: vi.fn(() => {
|
|
44
|
+
if (larkMock.wsStartError) return Promise.reject(larkMock.wsStartError);
|
|
45
|
+
return Promise.resolve();
|
|
46
|
+
}),
|
|
47
|
+
close: vi.fn(() => {
|
|
48
|
+
larkMock.wsClosed = true;
|
|
49
|
+
}),
|
|
50
|
+
};
|
|
51
|
+
}),
|
|
52
|
+
}));
|
|
53
|
+
|
|
54
|
+
const SILENT_LOG: GatewayLogger = {
|
|
55
|
+
info: () => {},
|
|
56
|
+
warn: () => {},
|
|
57
|
+
error: () => {},
|
|
58
|
+
debug: () => {},
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
const stubConfig = {
|
|
62
|
+
channels: [],
|
|
63
|
+
defaultRoute: { runtime: "claude-code", cwd: "/tmp" },
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
function makeStartCtx(abort: AbortController): {
|
|
67
|
+
ctx: ChannelStartContext;
|
|
68
|
+
envelopes: GatewayInboundEnvelope[];
|
|
69
|
+
statuses: Array<Record<string, unknown>>;
|
|
70
|
+
} {
|
|
71
|
+
const envelopes: GatewayInboundEnvelope[] = [];
|
|
72
|
+
const statuses: Array<Record<string, unknown>> = [];
|
|
73
|
+
return {
|
|
74
|
+
envelopes,
|
|
75
|
+
statuses,
|
|
76
|
+
ctx: {
|
|
77
|
+
config: stubConfig,
|
|
78
|
+
accountId: "ag_self",
|
|
79
|
+
abortSignal: abort.signal,
|
|
80
|
+
log: SILENT_LOG,
|
|
81
|
+
emit: async (env) => {
|
|
82
|
+
envelopes.push(env);
|
|
83
|
+
},
|
|
84
|
+
setStatus: (patch) => {
|
|
85
|
+
statuses.push({ ...patch });
|
|
86
|
+
},
|
|
87
|
+
},
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
let tmp: string;
|
|
92
|
+
|
|
93
|
+
beforeEach(() => {
|
|
94
|
+
tmp = mkdtempSync(path.join(tmpdir(), "feishu-channel-"));
|
|
95
|
+
larkMock.requests = [];
|
|
96
|
+
larkMock.responses = [];
|
|
97
|
+
larkMock.handlers = {};
|
|
98
|
+
larkMock.wsStartError = null;
|
|
99
|
+
larkMock.wsClosed = false;
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
afterEach(() => {
|
|
103
|
+
rmSync(tmp, { recursive: true, force: true });
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
describe("createFeishuChannel", () => {
|
|
107
|
+
it("normalizes non-text events and deduplicates repeated message ids", async () => {
|
|
108
|
+
larkMock.responses.push({
|
|
109
|
+
code: 0,
|
|
110
|
+
data: { pingBotInfo: { botID: "ou_bot", botName: "Bot" } },
|
|
111
|
+
});
|
|
112
|
+
const adapter = createFeishuChannel({
|
|
113
|
+
id: "gw_fs",
|
|
114
|
+
accountId: "ag_self",
|
|
115
|
+
appId: "cli_a",
|
|
116
|
+
appSecret: "sec",
|
|
117
|
+
allowedSenderIds: ["ou_alice"],
|
|
118
|
+
allowedChatIds: ["oc_chat"],
|
|
119
|
+
stateFile: path.join(tmp, "state.json"),
|
|
120
|
+
stateDebounceMs: 0,
|
|
121
|
+
});
|
|
122
|
+
const abort = new AbortController();
|
|
123
|
+
const { ctx, envelopes } = makeStartCtx(abort);
|
|
124
|
+
const started = adapter.start(ctx);
|
|
125
|
+
await vi.waitUntil(() => typeof larkMock.handlers["im.message.receive_v1"] === "function");
|
|
126
|
+
|
|
127
|
+
const event = {
|
|
128
|
+
sender: { sender_id: { open_id: "ou_alice" } },
|
|
129
|
+
message: {
|
|
130
|
+
message_id: "om_img_1",
|
|
131
|
+
chat_id: "oc_chat",
|
|
132
|
+
chat_type: "group",
|
|
133
|
+
message_type: "image",
|
|
134
|
+
content: JSON.stringify({ image_key: "img_v2_x" }),
|
|
135
|
+
},
|
|
136
|
+
};
|
|
137
|
+
await larkMock.handlers["im.message.receive_v1"]!(event);
|
|
138
|
+
await larkMock.handlers["im.message.receive_v1"]!(event);
|
|
139
|
+
abort.abort();
|
|
140
|
+
await started;
|
|
141
|
+
|
|
142
|
+
expect(envelopes).toHaveLength(1);
|
|
143
|
+
expect(envelopes[0]!.message.text).toBe("[image: img_v2_x]");
|
|
144
|
+
expect(envelopes[0]!.message.conversation.id).toBe("feishu:chat:oc_chat");
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it("sends text replies through Feishu reply API with thread mode", async () => {
|
|
148
|
+
larkMock.responses.push({ code: 0, data: { message_id: "om_reply_1" } });
|
|
149
|
+
const adapter = createFeishuChannel({
|
|
150
|
+
id: "gw_fs",
|
|
151
|
+
accountId: "ag_self",
|
|
152
|
+
appId: "cli_a",
|
|
153
|
+
appSecret: "sec",
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
const result = await adapter.send({
|
|
157
|
+
log: SILENT_LOG,
|
|
158
|
+
message: {
|
|
159
|
+
channel: "gw_fs",
|
|
160
|
+
accountId: "ag_self",
|
|
161
|
+
conversationId: "feishu:chat:oc_chat",
|
|
162
|
+
threadId: "om_root",
|
|
163
|
+
replyTo: "om_parent",
|
|
164
|
+
text: "hello",
|
|
165
|
+
},
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
expect(result.providerMessageId).toBe("om_reply_1");
|
|
169
|
+
expect(larkMock.requests).toHaveLength(1);
|
|
170
|
+
const req = larkMock.requests[0] as { url: string; data: Record<string, unknown> };
|
|
171
|
+
expect(req.url).toBe("/open-apis/im/v1/messages/om_parent/reply");
|
|
172
|
+
expect(req.data.reply_in_thread).toBe(true);
|
|
173
|
+
expect(req.data.msg_type).toBe("text");
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
it("uploads image attachments and sends them as Feishu image replies", async () => {
|
|
177
|
+
larkMock.responses.push(
|
|
178
|
+
{ code: 0, data: { image_key: "img_v2_uploaded" } },
|
|
179
|
+
{ code: 0, data: { message_id: "om_image_reply" } },
|
|
180
|
+
);
|
|
181
|
+
const adapter = createFeishuChannel({
|
|
182
|
+
id: "gw_fs",
|
|
183
|
+
accountId: "ag_self",
|
|
184
|
+
appId: "cli_a",
|
|
185
|
+
appSecret: "sec",
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
const result = await adapter.send({
|
|
189
|
+
log: SILENT_LOG,
|
|
190
|
+
message: {
|
|
191
|
+
channel: "gw_fs",
|
|
192
|
+
accountId: "ag_self",
|
|
193
|
+
conversationId: "feishu:chat:oc_chat",
|
|
194
|
+
text: "",
|
|
195
|
+
replyTo: "om_parent",
|
|
196
|
+
attachments: [
|
|
197
|
+
{
|
|
198
|
+
data: new Uint8Array([1, 2, 3]),
|
|
199
|
+
filename: "shot.png",
|
|
200
|
+
contentType: "image/png",
|
|
201
|
+
},
|
|
202
|
+
],
|
|
203
|
+
},
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
expect(result.providerMessageId).toBe("om_image_reply");
|
|
207
|
+
expect(larkMock.requests).toHaveLength(2);
|
|
208
|
+
const upload = larkMock.requests[0] as { url: string; data: Record<string, unknown> };
|
|
209
|
+
expect(upload.url).toBe("/open-apis/im/v1/images");
|
|
210
|
+
expect(upload.data.image_type).toBe("message");
|
|
211
|
+
const send = larkMock.requests[1] as { url: string; data: Record<string, unknown> };
|
|
212
|
+
expect(send.url).toBe("/open-apis/im/v1/messages/om_parent/reply");
|
|
213
|
+
expect(send.data.msg_type).toBe("image");
|
|
214
|
+
expect(JSON.parse(send.data.content as string)).toEqual({ image_key: "img_v2_uploaded" });
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
it("uploads file attachments and sends them as Feishu file messages", async () => {
|
|
218
|
+
larkMock.responses.push(
|
|
219
|
+
{ code: 0, data: { file_key: "file_v2_uploaded" } },
|
|
220
|
+
{ code: 0, data: { message_id: "om_file_msg" } },
|
|
221
|
+
);
|
|
222
|
+
const adapter = createFeishuChannel({
|
|
223
|
+
id: "gw_fs",
|
|
224
|
+
accountId: "ag_self",
|
|
225
|
+
appId: "cli_a",
|
|
226
|
+
appSecret: "sec",
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
const result = await adapter.send({
|
|
230
|
+
log: SILENT_LOG,
|
|
231
|
+
message: {
|
|
232
|
+
channel: "gw_fs",
|
|
233
|
+
accountId: "ag_self",
|
|
234
|
+
conversationId: "feishu:chat:oc_chat",
|
|
235
|
+
text: "",
|
|
236
|
+
attachments: [
|
|
237
|
+
{
|
|
238
|
+
data: new Uint8Array([4, 5, 6]),
|
|
239
|
+
filename: "report.pdf",
|
|
240
|
+
contentType: "application/pdf",
|
|
241
|
+
},
|
|
242
|
+
],
|
|
243
|
+
},
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
expect(result.providerMessageId).toBe("om_file_msg");
|
|
247
|
+
expect(larkMock.requests).toHaveLength(2);
|
|
248
|
+
const upload = larkMock.requests[0] as { url: string; data: Record<string, unknown> };
|
|
249
|
+
expect(upload.url).toBe("/open-apis/im/v1/files");
|
|
250
|
+
expect(upload.data.file_type).toBe("pdf");
|
|
251
|
+
expect(upload.data.file_name).toBe("report.pdf");
|
|
252
|
+
const send = larkMock.requests[1] as { url: string; data: Record<string, unknown> };
|
|
253
|
+
expect(send.url).toBe("/open-apis/im/v1/messages");
|
|
254
|
+
expect(send.data.msg_type).toBe("file");
|
|
255
|
+
expect(JSON.parse(send.data.content as string)).toEqual({ file_key: "file_v2_uploaded" });
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
it("exposes typing as a safe no-op because Feishu has no bot typing API", async () => {
|
|
259
|
+
const debug = vi.fn();
|
|
260
|
+
const adapter = createFeishuChannel({
|
|
261
|
+
id: "gw_fs",
|
|
262
|
+
accountId: "ag_self",
|
|
263
|
+
appId: "cli_a",
|
|
264
|
+
appSecret: "sec",
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
await adapter.typing?.({
|
|
268
|
+
traceId: "feishu:om_1",
|
|
269
|
+
accountId: "ag_self",
|
|
270
|
+
conversationId: "feishu:chat:oc_chat",
|
|
271
|
+
log: { ...SILENT_LOG, debug },
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
expect(larkMock.requests).toHaveLength(0);
|
|
275
|
+
expect(debug).toHaveBeenCalledWith(
|
|
276
|
+
"feishu typing ignored: no native bot typing API",
|
|
277
|
+
expect.objectContaining({ channel: "gw_fs" }),
|
|
278
|
+
);
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
it("surfaces websocket start failures in channel status", async () => {
|
|
282
|
+
larkMock.responses.push({
|
|
283
|
+
code: 0,
|
|
284
|
+
data: { pingBotInfo: { botID: "ou_bot", botName: "Bot" } },
|
|
285
|
+
});
|
|
286
|
+
larkMock.wsStartError = new Error("ws denied");
|
|
287
|
+
const adapter = createFeishuChannel({
|
|
288
|
+
id: "gw_fs",
|
|
289
|
+
accountId: "ag_self",
|
|
290
|
+
appId: "cli_a",
|
|
291
|
+
appSecret: "sec",
|
|
292
|
+
allowedSenderIds: ["ou_alice"],
|
|
293
|
+
stateFile: path.join(tmp, "state.json"),
|
|
294
|
+
stateDebounceMs: 0,
|
|
295
|
+
});
|
|
296
|
+
const abort = new AbortController();
|
|
297
|
+
const { ctx, statuses } = makeStartCtx(abort);
|
|
298
|
+
const started = adapter.start(ctx);
|
|
299
|
+
await vi.waitUntil(() => statuses.some((s) => s.lastError === "ws denied"));
|
|
300
|
+
abort.abort();
|
|
301
|
+
await started;
|
|
302
|
+
|
|
303
|
+
expect(adapter.status()?.lastError).toBe("ws denied");
|
|
304
|
+
expect(adapter.status()?.authorized).toBe(false);
|
|
305
|
+
});
|
|
306
|
+
});
|
|
@@ -55,6 +55,12 @@ export interface BotCordChannelClient {
|
|
|
55
55
|
text: string,
|
|
56
56
|
options?: { replyTo?: string; topic?: string },
|
|
57
57
|
): Promise<{ hub_msg_id?: string; message_id?: string } & Record<string, unknown>>;
|
|
58
|
+
sendTypedMessage?(
|
|
59
|
+
to: string,
|
|
60
|
+
type: "result" | "error",
|
|
61
|
+
text: string,
|
|
62
|
+
options?: { replyTo?: string; topic?: string },
|
|
63
|
+
): Promise<{ hub_msg_id?: string; message_id?: string } & Record<string, unknown>>;
|
|
58
64
|
getHubUrl(): string;
|
|
59
65
|
onTokenRefresh?: (token: string, expiresAt: number) => void;
|
|
60
66
|
}
|
|
@@ -804,7 +810,10 @@ export function createBotCordChannel(options: BotCordChannelOptions): ChannelAda
|
|
|
804
810
|
const options: { replyTo?: string; topic?: string } = {};
|
|
805
811
|
if (message.replyTo) options.replyTo = message.replyTo;
|
|
806
812
|
if (message.threadId) options.topic = message.threadId;
|
|
807
|
-
const resp =
|
|
813
|
+
const resp =
|
|
814
|
+
message.type === "error" && client.sendTypedMessage
|
|
815
|
+
? await client.sendTypedMessage(message.conversationId, "error", message.text, options)
|
|
816
|
+
: await client.sendMessage(message.conversationId, message.text, options);
|
|
808
817
|
const providerMessageId =
|
|
809
818
|
(resp && typeof resp.hub_msg_id === "string" && resp.hub_msg_id) ||
|
|
810
819
|
(resp && typeof (resp as { message_id?: unknown }).message_id === "string"
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Feishu/Lark PersonalAgent app registration helpers.
|
|
3
|
+
*
|
|
4
|
+
* Mirrors the flow used by `@larksuite/openclaw-lark-tools`:
|
|
5
|
+
* POST /oauth/v1/app/registration action=init
|
|
6
|
+
* POST /oauth/v1/app/registration action=begin
|
|
7
|
+
* POST /oauth/v1/app/registration action=poll
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import type { FetchLike } from "./http-types.js";
|
|
11
|
+
|
|
12
|
+
export type FeishuDomain = "feishu" | "lark";
|
|
13
|
+
|
|
14
|
+
const FEISHU_ACCOUNTS_BASE = "https://accounts.feishu.cn";
|
|
15
|
+
const LARK_ACCOUNTS_BASE = "https://accounts.larksuite.com";
|
|
16
|
+
|
|
17
|
+
export interface FeishuRegistrationOptions {
|
|
18
|
+
domain?: FeishuDomain;
|
|
19
|
+
fetchImpl?: FetchLike;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface FeishuRegistrationStart {
|
|
23
|
+
deviceCode: string;
|
|
24
|
+
verificationUriComplete: string;
|
|
25
|
+
verificationUri?: string;
|
|
26
|
+
expiresIn: number;
|
|
27
|
+
interval: number;
|
|
28
|
+
domain: FeishuDomain;
|
|
29
|
+
raw: Record<string, unknown>;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface FeishuRegistrationPoll {
|
|
33
|
+
status: "pending" | "confirmed" | "expired" | "denied" | "failed";
|
|
34
|
+
appId?: string;
|
|
35
|
+
appSecret?: string;
|
|
36
|
+
userOpenId?: string;
|
|
37
|
+
domain: FeishuDomain;
|
|
38
|
+
interval?: number;
|
|
39
|
+
error?: string;
|
|
40
|
+
raw: Record<string, unknown>;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function baseForDomain(domain: FeishuDomain): string {
|
|
44
|
+
return domain === "lark" ? LARK_ACCOUNTS_BASE : FEISHU_ACCOUNTS_BASE;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function fetcher(opts: FeishuRegistrationOptions): FetchLike {
|
|
48
|
+
return opts.fetchImpl ?? ((globalThis.fetch as unknown) as FetchLike);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
async function postRegistration(
|
|
52
|
+
action: string,
|
|
53
|
+
params: Record<string, string>,
|
|
54
|
+
opts: FeishuRegistrationOptions,
|
|
55
|
+
): Promise<Record<string, unknown>> {
|
|
56
|
+
const domain = opts.domain ?? "feishu";
|
|
57
|
+
const res = await fetcher(opts)(`${baseForDomain(domain)}/oauth/v1/app/registration`, {
|
|
58
|
+
method: "POST",
|
|
59
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
60
|
+
body: new URLSearchParams({ action, ...params }).toString(),
|
|
61
|
+
});
|
|
62
|
+
const raw = await res.text();
|
|
63
|
+
if (!raw) return {};
|
|
64
|
+
try {
|
|
65
|
+
return JSON.parse(raw) as Record<string, unknown>;
|
|
66
|
+
} catch {
|
|
67
|
+
throw new Error(`feishu registration ${action}: non-json response`);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export async function startFeishuRegistration(
|
|
72
|
+
opts: FeishuRegistrationOptions = {},
|
|
73
|
+
): Promise<FeishuRegistrationStart> {
|
|
74
|
+
const domain = opts.domain ?? "feishu";
|
|
75
|
+
const init = await postRegistration("init", {}, { ...opts, domain });
|
|
76
|
+
const methods = Array.isArray(init.supported_auth_methods)
|
|
77
|
+
? init.supported_auth_methods.map(String)
|
|
78
|
+
: [];
|
|
79
|
+
if (methods.length > 0 && !methods.includes("client_secret")) {
|
|
80
|
+
throw new Error("feishu registration: client_secret auth is not supported");
|
|
81
|
+
}
|
|
82
|
+
const begin = await postRegistration(
|
|
83
|
+
"begin",
|
|
84
|
+
{
|
|
85
|
+
archetype: "PersonalAgent",
|
|
86
|
+
auth_method: "client_secret",
|
|
87
|
+
request_user_info: "open_id",
|
|
88
|
+
},
|
|
89
|
+
{ ...opts, domain },
|
|
90
|
+
);
|
|
91
|
+
const deviceCode = typeof begin.device_code === "string" ? begin.device_code : "";
|
|
92
|
+
const verificationUriComplete =
|
|
93
|
+
typeof begin.verification_uri_complete === "string"
|
|
94
|
+
? begin.verification_uri_complete
|
|
95
|
+
: "";
|
|
96
|
+
if (!deviceCode || !verificationUriComplete) {
|
|
97
|
+
throw new Error("feishu registration: missing device_code or verification_uri_complete");
|
|
98
|
+
}
|
|
99
|
+
return {
|
|
100
|
+
deviceCode,
|
|
101
|
+
verificationUriComplete,
|
|
102
|
+
...(typeof begin.verification_uri === "string"
|
|
103
|
+
? { verificationUri: begin.verification_uri }
|
|
104
|
+
: {}),
|
|
105
|
+
expiresIn: typeof begin.expire_in === "number" ? begin.expire_in : 600,
|
|
106
|
+
interval: typeof begin.interval === "number" ? begin.interval : 5,
|
|
107
|
+
domain,
|
|
108
|
+
raw: begin,
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export async function pollFeishuRegistration(
|
|
113
|
+
deviceCode: string,
|
|
114
|
+
opts: FeishuRegistrationOptions = {},
|
|
115
|
+
): Promise<FeishuRegistrationPoll> {
|
|
116
|
+
const domain = opts.domain ?? "feishu";
|
|
117
|
+
const data = await postRegistration(
|
|
118
|
+
"poll",
|
|
119
|
+
{ device_code: deviceCode },
|
|
120
|
+
{ ...opts, domain },
|
|
121
|
+
);
|
|
122
|
+
const tenantBrand =
|
|
123
|
+
typeof (data.user_info as Record<string, unknown> | undefined)?.tenant_brand === "string"
|
|
124
|
+
? String((data.user_info as Record<string, unknown>).tenant_brand)
|
|
125
|
+
: "";
|
|
126
|
+
const resolvedDomain: FeishuDomain = tenantBrand === "lark" ? "lark" : domain;
|
|
127
|
+
const appId = typeof data.client_id === "string" ? data.client_id : undefined;
|
|
128
|
+
const appSecret =
|
|
129
|
+
typeof data.client_secret === "string" ? data.client_secret : undefined;
|
|
130
|
+
if (appId && appSecret) {
|
|
131
|
+
const userInfo = data.user_info as Record<string, unknown> | undefined;
|
|
132
|
+
return {
|
|
133
|
+
status: "confirmed",
|
|
134
|
+
appId,
|
|
135
|
+
appSecret,
|
|
136
|
+
userOpenId: typeof userInfo?.open_id === "string" ? userInfo.open_id : undefined,
|
|
137
|
+
domain: resolvedDomain,
|
|
138
|
+
raw: data,
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
const error = typeof data.error === "string" ? data.error : "";
|
|
142
|
+
if (!error || error === "authorization_pending") {
|
|
143
|
+
return { status: "pending", domain: resolvedDomain, raw: data };
|
|
144
|
+
}
|
|
145
|
+
if (error === "slow_down") {
|
|
146
|
+
return { status: "pending", domain: resolvedDomain, interval: 10, raw: data };
|
|
147
|
+
}
|
|
148
|
+
if (error === "access_denied") {
|
|
149
|
+
return { status: "denied", domain: resolvedDomain, error, raw: data };
|
|
150
|
+
}
|
|
151
|
+
if (error === "expired_token" || error === "invalid_grant") {
|
|
152
|
+
return { status: "expired", domain: resolvedDomain, error, raw: data };
|
|
153
|
+
}
|
|
154
|
+
return { status: "failed", domain: resolvedDomain, error: error || "unknown", raw: data };
|
|
155
|
+
}
|