@botcord/daemon 0.2.58 → 0.2.60
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/diagnostics.d.ts +1 -0
- package/dist/diagnostics.js +35 -6
- 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/channels/wechat.js +26 -2
- package/dist/gateway/dispatcher.d.ts +3 -0
- package/dist/gateway/dispatcher.js +190 -30
- package/dist/gateway/policy-resolver.d.ts +10 -6
- package/dist/gateway/types.d.ts +1 -1
- package/dist/gateway-control.d.ts +8 -1
- package/dist/gateway-control.js +171 -18
- package/dist/index.js +9 -3
- package/dist/log.d.ts +9 -0
- package/dist/log.js +89 -1
- package/dist/provision.js +7 -1
- package/package.json +2 -1
- package/src/__tests__/cross-room.test.ts +2 -0
- package/src/__tests__/diagnostics.test.ts +37 -1
- package/src/__tests__/gateway-control.test.ts +84 -0
- package/src/__tests__/log.test.ts +28 -1
- package/src/__tests__/policy-updated-handler.test.ts +5 -7
- package/src/__tests__/third-party-gateway.test.ts +28 -0
- package/src/__tests__/wechat-channel.test.ts +47 -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/diagnostics.ts +36 -6
- package/src/gateway/__tests__/dispatcher.test.ts +62 -4
- package/src/gateway/__tests__/feishu-channel.test.ts +306 -0
- 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/channels/wechat.ts +29 -2
- package/src/gateway/dispatcher.ts +216 -29
- package/src/gateway/policy-resolver.ts +19 -11
- package/src/gateway/types.ts +1 -1
- package/src/gateway-control.ts +188 -17
- package/src/index.ts +9 -3
- package/src/log.ts +100 -1
- package/src/provision.ts +13 -1
|
@@ -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
|
+
});
|
|
@@ -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
|
+
}
|