@feihan-im/openclaw-plugin 0.1.0
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/LICENSE +201 -0
- package/README.en.md +112 -0
- package/README.md +112 -0
- package/dist/index.cjs +650 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +62 -0
- package/dist/index.d.ts +62 -0
- package/dist/index.js +627 -0
- package/dist/index.js.map +1 -0
- package/package.json +74 -0
- package/src/channel.ts +61 -0
- package/src/config.test.ts +251 -0
- package/src/config.ts +162 -0
- package/src/core/feihan-client.test.ts +140 -0
- package/src/core/feihan-client.ts +319 -0
- package/src/index.test.ts +164 -0
- package/src/index.ts +112 -0
- package/src/messaging/inbound.test.ts +560 -0
- package/src/messaging/inbound.ts +396 -0
- package/src/messaging/outbound.test.ts +172 -0
- package/src/messaging/outbound.ts +176 -0
- package/src/targets.test.ts +91 -0
- package/src/targets.ts +41 -0
- package/src/types.test.ts +10 -0
- package/src/types.ts +115 -0
- package/src/typings/feihan-sdk.d.ts +23 -0
package/src/config.ts
ADDED
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
// Copyright (c) 2026 上海飞函安全科技有限公司 (Shanghai Feihan Security Technology Co., Ltd.)
|
|
2
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
|
|
4
|
+
import type { FeihanChannelConfig, FeihanAccountConfig } from "./types.js";
|
|
5
|
+
|
|
6
|
+
const DEFAULTS = {
|
|
7
|
+
enableEncryption: true,
|
|
8
|
+
requestTimeout: 30_000,
|
|
9
|
+
requireMention: true,
|
|
10
|
+
inboundWhitelist: [] as string[],
|
|
11
|
+
} as const;
|
|
12
|
+
|
|
13
|
+
const ENV_PREFIX = "FEIHAN_";
|
|
14
|
+
|
|
15
|
+
function getChannelConfig(cfg: unknown): FeihanChannelConfig | undefined {
|
|
16
|
+
const root = cfg as Record<string, unknown> | undefined;
|
|
17
|
+
return root?.channels
|
|
18
|
+
? ((root.channels as Record<string, unknown>).feihan as
|
|
19
|
+
| FeihanChannelConfig
|
|
20
|
+
| undefined)
|
|
21
|
+
: undefined;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function readEnvConfig(): Partial<FeihanAccountConfig> {
|
|
25
|
+
const env = process.env;
|
|
26
|
+
const result: Partial<FeihanAccountConfig> = {};
|
|
27
|
+
|
|
28
|
+
if (env[`${ENV_PREFIX}APP_ID`]) result.appId = env[`${ENV_PREFIX}APP_ID`];
|
|
29
|
+
if (env[`${ENV_PREFIX}APP_SECRET`])
|
|
30
|
+
result.appSecret = env[`${ENV_PREFIX}APP_SECRET`];
|
|
31
|
+
if (env[`${ENV_PREFIX}BACKEND_URL`])
|
|
32
|
+
result.backendUrl = env[`${ENV_PREFIX}BACKEND_URL`];
|
|
33
|
+
if (env[`${ENV_PREFIX}ENABLE_ENCRYPTION`] !== undefined)
|
|
34
|
+
result.enableEncryption =
|
|
35
|
+
env[`${ENV_PREFIX}ENABLE_ENCRYPTION`] !== "false";
|
|
36
|
+
if (env[`${ENV_PREFIX}REQUEST_TIMEOUT`])
|
|
37
|
+
result.requestTimeout = Number(env[`${ENV_PREFIX}REQUEST_TIMEOUT`]);
|
|
38
|
+
if (env[`${ENV_PREFIX}REQUIRE_MENTION`] !== undefined)
|
|
39
|
+
result.requireMention = env[`${ENV_PREFIX}REQUIRE_MENTION`] !== "false";
|
|
40
|
+
if (env[`${ENV_PREFIX}BOT_USER_ID`])
|
|
41
|
+
result.botUserId = env[`${ENV_PREFIX}BOT_USER_ID`];
|
|
42
|
+
|
|
43
|
+
return result;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function listAccountIds(cfg: unknown): string[] {
|
|
47
|
+
const ch = getChannelConfig(cfg);
|
|
48
|
+
if (!ch) {
|
|
49
|
+
// Check env vars as fallback
|
|
50
|
+
if (process.env[`${ENV_PREFIX}APP_ID`]) return ["default"];
|
|
51
|
+
return [];
|
|
52
|
+
}
|
|
53
|
+
if (ch.accounts) return Object.keys(ch.accounts);
|
|
54
|
+
if (ch.appId) return ["default"];
|
|
55
|
+
// env var fallback
|
|
56
|
+
if (process.env[`${ENV_PREFIX}APP_ID`]) return ["default"];
|
|
57
|
+
return [];
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function resolveAccountConfig(
|
|
61
|
+
cfg: unknown,
|
|
62
|
+
accountId?: string,
|
|
63
|
+
): FeihanAccountConfig {
|
|
64
|
+
const ch = getChannelConfig(cfg);
|
|
65
|
+
const id = accountId ?? "default";
|
|
66
|
+
const envConfig = readEnvConfig();
|
|
67
|
+
|
|
68
|
+
const raw = ch?.accounts?.[id] ?? ch;
|
|
69
|
+
|
|
70
|
+
return {
|
|
71
|
+
accountId: id,
|
|
72
|
+
enabled: raw?.enabled ?? true,
|
|
73
|
+
appId: raw?.appId ?? envConfig.appId ?? "",
|
|
74
|
+
appSecret: raw?.appSecret ?? envConfig.appSecret ?? "",
|
|
75
|
+
backendUrl: raw?.backendUrl ?? envConfig.backendUrl ?? "",
|
|
76
|
+
enableEncryption:
|
|
77
|
+
raw?.enableEncryption ?? envConfig.enableEncryption ?? DEFAULTS.enableEncryption,
|
|
78
|
+
requestTimeout:
|
|
79
|
+
raw?.requestTimeout ?? envConfig.requestTimeout ?? DEFAULTS.requestTimeout,
|
|
80
|
+
requireMention:
|
|
81
|
+
raw?.requireMention ?? envConfig.requireMention ?? DEFAULTS.requireMention,
|
|
82
|
+
botUserId: raw?.botUserId ?? envConfig.botUserId,
|
|
83
|
+
inboundWhitelist: raw?.inboundWhitelist ?? [...DEFAULTS.inboundWhitelist],
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export interface ConfigValidationError {
|
|
88
|
+
field: string;
|
|
89
|
+
message: string;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export function validateAccountConfig(
|
|
93
|
+
config: FeihanAccountConfig,
|
|
94
|
+
): ConfigValidationError[] {
|
|
95
|
+
const errors: ConfigValidationError[] = [];
|
|
96
|
+
|
|
97
|
+
if (!config.appId) {
|
|
98
|
+
errors.push({
|
|
99
|
+
field: "appId",
|
|
100
|
+
message: `Account "${config.accountId}": appId is required. Set it in channels.feihan.appId or FEIHAN_APP_ID env var.`,
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
if (!config.appSecret) {
|
|
105
|
+
errors.push({
|
|
106
|
+
field: "appSecret",
|
|
107
|
+
message: `Account "${config.accountId}": appSecret is required. Set it in channels.feihan.appSecret or FEIHAN_APP_SECRET env var.`,
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
if (!config.backendUrl) {
|
|
112
|
+
errors.push({
|
|
113
|
+
field: "backendUrl",
|
|
114
|
+
message: `Account "${config.accountId}": backendUrl is required. Set it in channels.feihan.backendUrl or FEIHAN_BACKEND_URL env var.`,
|
|
115
|
+
});
|
|
116
|
+
} else if (
|
|
117
|
+
!config.backendUrl.startsWith("http://") &&
|
|
118
|
+
!config.backendUrl.startsWith("https://")
|
|
119
|
+
) {
|
|
120
|
+
errors.push({
|
|
121
|
+
field: "backendUrl",
|
|
122
|
+
message: `Account "${config.accountId}": backendUrl must start with http:// or https:// (got "${config.backendUrl}").`,
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
if (
|
|
127
|
+
typeof config.requestTimeout !== "number" ||
|
|
128
|
+
!Number.isFinite(config.requestTimeout) ||
|
|
129
|
+
config.requestTimeout <= 0
|
|
130
|
+
) {
|
|
131
|
+
errors.push({
|
|
132
|
+
field: "requestTimeout",
|
|
133
|
+
message: `Account "${config.accountId}": requestTimeout must be a positive number in milliseconds (got ${config.requestTimeout}).`,
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
return errors;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export function resolveAndValidateAccountConfig(
|
|
141
|
+
cfg: unknown,
|
|
142
|
+
accountId?: string,
|
|
143
|
+
): FeihanAccountConfig {
|
|
144
|
+
const config = resolveAccountConfig(cfg, accountId);
|
|
145
|
+
const errors = validateAccountConfig(config);
|
|
146
|
+
|
|
147
|
+
if (errors.length > 0) {
|
|
148
|
+
const messages = errors.map((e) => ` - ${e.message}`).join("\n");
|
|
149
|
+
throw new Error(
|
|
150
|
+
`[feihan] Invalid config for account "${config.accountId}":\n${messages}`,
|
|
151
|
+
);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
return config;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
export function listEnabledAccountConfigs(cfg: unknown): FeihanAccountConfig[] {
|
|
158
|
+
const ids = listAccountIds(cfg);
|
|
159
|
+
return ids
|
|
160
|
+
.map((id) => resolveAccountConfig(cfg, id))
|
|
161
|
+
.filter((account) => account.enabled);
|
|
162
|
+
}
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
// Copyright (c) 2026 上海飞函安全科技有限公司 (Shanghai Feihan Security Technology Co., Ltd.)
|
|
2
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
|
|
4
|
+
import { describe, it, expect } from "vitest";
|
|
5
|
+
import { ApiError } from "@feihan-im/sdk";
|
|
6
|
+
import { normalizeSdkEvent, isAuthError, type SdkMessageEvent } from "./feihan-client.js";
|
|
7
|
+
|
|
8
|
+
describe("isAuthError", () => {
|
|
9
|
+
it("returns true for ApiError with auth failure code 40000006", () => {
|
|
10
|
+
const err = new ApiError(40000006, "鉴权失败", "log123");
|
|
11
|
+
expect(isAuthError(err)).toBe(true);
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
it("returns false for ApiError with a different code", () => {
|
|
15
|
+
const err = new ApiError(50000001, "server error", "log456");
|
|
16
|
+
expect(isAuthError(err)).toBe(false);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it("returns false for a plain Error", () => {
|
|
20
|
+
expect(isAuthError(new Error("auth failed"))).toBe(false);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it("returns false for non-error values", () => {
|
|
24
|
+
expect(isAuthError(null)).toBe(false);
|
|
25
|
+
expect(isAuthError(undefined)).toBe(false);
|
|
26
|
+
expect(isAuthError("error string")).toBe(false);
|
|
27
|
+
expect(isAuthError(40000006)).toBe(false);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it("returns false for an object with matching code but not ApiError instance", () => {
|
|
31
|
+
const fakeErr = { code: 40000006, msg: "鉴权失败", message: "auth" };
|
|
32
|
+
expect(isAuthError(fakeErr)).toBe(false);
|
|
33
|
+
});
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
describe("normalizeSdkEvent", () => {
|
|
37
|
+
it("converts snake_case SDK event to camelCase plugin format", () => {
|
|
38
|
+
const sdkEvent: SdkMessageEvent = {
|
|
39
|
+
header: { event_id: "evt_001", event_type: "im.v1.message.receive" },
|
|
40
|
+
body: {
|
|
41
|
+
message: {
|
|
42
|
+
message_id: "msg_001",
|
|
43
|
+
message_type: "text",
|
|
44
|
+
message_content: { text: { content: "hello" } },
|
|
45
|
+
chat_id: "chat_001",
|
|
46
|
+
chat_type: "direct",
|
|
47
|
+
sender_id: {
|
|
48
|
+
user_id: "user_001",
|
|
49
|
+
union_user_id: "union_001",
|
|
50
|
+
open_user_id: "open_001",
|
|
51
|
+
},
|
|
52
|
+
message_created_at: 1711900000000,
|
|
53
|
+
mention_user_list: [
|
|
54
|
+
{ user_id: { user_id: "user_002" } },
|
|
55
|
+
{ user_id: { user_id: "user_003" } },
|
|
56
|
+
],
|
|
57
|
+
},
|
|
58
|
+
},
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
const result = normalizeSdkEvent(sdkEvent);
|
|
62
|
+
expect(result).not.toBeNull();
|
|
63
|
+
expect(result!.message.messageId).toBe("msg_001");
|
|
64
|
+
expect(result!.message.messageType).toBe("text");
|
|
65
|
+
expect(result!.message.chatId).toBe("chat_001");
|
|
66
|
+
expect(result!.message.chatType).toBe("direct");
|
|
67
|
+
expect(result!.message.sender.userId).toBe("user_001");
|
|
68
|
+
expect(result!.message.createdAt).toBe(1711900000000);
|
|
69
|
+
expect(result!.message.mentionUserList).toHaveLength(2);
|
|
70
|
+
expect(result!.message.mentionUserList![0].userId).toBe("user_002");
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it("returns null for event without body.message", () => {
|
|
74
|
+
expect(normalizeSdkEvent({ body: {} })).toBeNull();
|
|
75
|
+
expect(normalizeSdkEvent({})).toBeNull();
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it("defaults missing fields", () => {
|
|
79
|
+
const sdkEvent: SdkMessageEvent = {
|
|
80
|
+
body: { message: {} },
|
|
81
|
+
};
|
|
82
|
+
const result = normalizeSdkEvent(sdkEvent);
|
|
83
|
+
expect(result).not.toBeNull();
|
|
84
|
+
expect(result!.message.messageId).toBe("");
|
|
85
|
+
expect(result!.message.chatType).toBe("direct");
|
|
86
|
+
expect(result!.message.sender.userId).toBe("");
|
|
87
|
+
expect(result!.message.mentionUserList).toEqual([]);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it("preserves messageContent as-is", () => {
|
|
91
|
+
const content = { text: { content: "test" }, extra: true };
|
|
92
|
+
const sdkEvent: SdkMessageEvent = {
|
|
93
|
+
body: { message: { message_content: content } },
|
|
94
|
+
};
|
|
95
|
+
const result = normalizeSdkEvent(sdkEvent);
|
|
96
|
+
expect(result!.message.messageContent).toBe(content);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it("handles sender_id as legacy flat string", () => {
|
|
100
|
+
const sdkEvent: SdkMessageEvent = {
|
|
101
|
+
body: {
|
|
102
|
+
message: {
|
|
103
|
+
message_id: "msg_002",
|
|
104
|
+
sender_id: "flat_user_id" as unknown as SdkMessageEvent["body"] extends { message?: infer M } ? M extends { sender_id?: infer S } ? S : never : never,
|
|
105
|
+
},
|
|
106
|
+
},
|
|
107
|
+
};
|
|
108
|
+
const result = normalizeSdkEvent(sdkEvent);
|
|
109
|
+
expect(result).not.toBeNull();
|
|
110
|
+
expect(result!.message.sender.userId).toBe("flat_user_id");
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it("handles message_created_at as Int64 string", () => {
|
|
114
|
+
const sdkEvent: SdkMessageEvent = {
|
|
115
|
+
body: {
|
|
116
|
+
message: {
|
|
117
|
+
message_id: "msg_003",
|
|
118
|
+
message_created_at: "1711900000000",
|
|
119
|
+
},
|
|
120
|
+
},
|
|
121
|
+
};
|
|
122
|
+
const result = normalizeSdkEvent(sdkEvent);
|
|
123
|
+
expect(result).not.toBeNull();
|
|
124
|
+
expect(result!.message.createdAt).toBe(1711900000000);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it("falls back to open_user_id when user_id is missing", () => {
|
|
128
|
+
const sdkEvent: SdkMessageEvent = {
|
|
129
|
+
body: {
|
|
130
|
+
message: {
|
|
131
|
+
sender_id: {
|
|
132
|
+
open_user_id: "open_123",
|
|
133
|
+
},
|
|
134
|
+
},
|
|
135
|
+
},
|
|
136
|
+
};
|
|
137
|
+
const result = normalizeSdkEvent(sdkEvent);
|
|
138
|
+
expect(result!.message.sender.userId).toBe("open_123");
|
|
139
|
+
});
|
|
140
|
+
});
|
|
@@ -0,0 +1,319 @@
|
|
|
1
|
+
// Copyright (c) 2026 上海飞函安全科技有限公司 (Shanghai Feihan Security Technology Co., Ltd.)
|
|
2
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Feihan client manager — wraps the @feihan-im/sdk
|
|
6
|
+
* and provides per-account lifecycle management.
|
|
7
|
+
*
|
|
8
|
+
* This module bridges the SDK's snake_case API with the plugin's camelCase
|
|
9
|
+
* conventions and manages client instances by account ID.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { FeihanClient, LoggerLevel, ApiError } from "@feihan-im/sdk";
|
|
13
|
+
import type { Logger } from "@feihan-im/sdk";
|
|
14
|
+
import type { FeihanAccountConfig, ConnectionState, FeihanMessageEvent } from "../types.js";
|
|
15
|
+
|
|
16
|
+
// ---------------------------------------------------------------------------
|
|
17
|
+
// Auth error detection
|
|
18
|
+
// ---------------------------------------------------------------------------
|
|
19
|
+
|
|
20
|
+
/** Feihan auth failure error code (鉴权失败). */
|
|
21
|
+
const AUTH_ERROR_CODE = 40000006;
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Check whether an error is a Feihan auth/token failure.
|
|
25
|
+
* Returns true for ApiError with code 40000006, which indicates
|
|
26
|
+
* the token has expired or is otherwise invalid.
|
|
27
|
+
*/
|
|
28
|
+
export function isAuthError(err: unknown): boolean {
|
|
29
|
+
return err instanceof ApiError && err.code === AUTH_ERROR_CODE;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// ---------------------------------------------------------------------------
|
|
33
|
+
// Constants — the new SDK doesn't re-export message type enums from its
|
|
34
|
+
// top-level barrel, so we define the constant locally. The value matches
|
|
35
|
+
// the SDK's MessageType_TEXT = 'text' in message_enum.ts.
|
|
36
|
+
// ---------------------------------------------------------------------------
|
|
37
|
+
|
|
38
|
+
export const MessageType_TEXT = "text";
|
|
39
|
+
|
|
40
|
+
// ---------------------------------------------------------------------------
|
|
41
|
+
// Types
|
|
42
|
+
// ---------------------------------------------------------------------------
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* The SDK client instance returned by FeihanClient.create().
|
|
46
|
+
*
|
|
47
|
+
* The new @feihan-im/sdk uses:
|
|
48
|
+
* - client.Im.v1.Message.sendMessage(req) — snake_case request fields
|
|
49
|
+
* - client.Im.v1.Message.Event.onMessageReceive(handler) — sync, void return
|
|
50
|
+
* - client.Im.v1.Chat.createTyping(req) — snake_case request fields
|
|
51
|
+
* - client.preheat() / client.close() — camelCase lifecycle methods
|
|
52
|
+
*/
|
|
53
|
+
export type SdkClient = FeihanClient;
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Local send-message request shape matching the SDK's SendMessageReq.
|
|
57
|
+
* Only text is used for now; add specific fields (image, card, etc.)
|
|
58
|
+
* as outbound capabilities grow.
|
|
59
|
+
*/
|
|
60
|
+
export interface SendMessageReq {
|
|
61
|
+
chat_id?: string;
|
|
62
|
+
message_type?: string;
|
|
63
|
+
message_content?: {
|
|
64
|
+
text?: { content?: string };
|
|
65
|
+
};
|
|
66
|
+
reply_message_id?: string;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Raw event shape from the SDK's onMessageReceive handler.
|
|
71
|
+
*
|
|
72
|
+
* The new SDK delivers typed events with shape:
|
|
73
|
+
* { header: EventHeader, body: { message?: Message } }
|
|
74
|
+
*
|
|
75
|
+
* Message fields are snake_case. sender_id is a UserId object:
|
|
76
|
+
* { user_id?, union_user_id?, open_user_id? }
|
|
77
|
+
*/
|
|
78
|
+
export interface SdkMessageEvent {
|
|
79
|
+
header?: {
|
|
80
|
+
event_id?: string;
|
|
81
|
+
event_type?: string;
|
|
82
|
+
event_created_at?: string;
|
|
83
|
+
};
|
|
84
|
+
body?: {
|
|
85
|
+
message?: {
|
|
86
|
+
message_id?: string;
|
|
87
|
+
message_type?: string;
|
|
88
|
+
message_status?: string;
|
|
89
|
+
message_content?: unknown;
|
|
90
|
+
message_created_at?: string | number;
|
|
91
|
+
chat_id?: string;
|
|
92
|
+
chat_seq_id?: string | number;
|
|
93
|
+
sender_id?: {
|
|
94
|
+
user_id?: string;
|
|
95
|
+
union_user_id?: string;
|
|
96
|
+
open_user_id?: string;
|
|
97
|
+
} | string;
|
|
98
|
+
// These may appear in group chats
|
|
99
|
+
chat_type?: string;
|
|
100
|
+
mention_user_list?: Array<{
|
|
101
|
+
user_id?: {
|
|
102
|
+
user_id?: string;
|
|
103
|
+
union_user_id?: string;
|
|
104
|
+
open_user_id?: string;
|
|
105
|
+
};
|
|
106
|
+
user_name?: string;
|
|
107
|
+
}>;
|
|
108
|
+
};
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// ---------------------------------------------------------------------------
|
|
113
|
+
// Client state
|
|
114
|
+
// ---------------------------------------------------------------------------
|
|
115
|
+
|
|
116
|
+
export interface ManagedClient {
|
|
117
|
+
client: SdkClient;
|
|
118
|
+
config: FeihanAccountConfig;
|
|
119
|
+
/** The raw handler reference, needed for offMessageReceive. */
|
|
120
|
+
eventHandler?: (event: SdkMessageEvent) => void;
|
|
121
|
+
/** Diagnostic connection state. Updated on create/destroy. */
|
|
122
|
+
connectionState: ConnectionState;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const clients = new Map<string, ManagedClient>();
|
|
126
|
+
|
|
127
|
+
// ---------------------------------------------------------------------------
|
|
128
|
+
// Logger adapter — bridge plugin's simple log callback to SDK's Logger interface
|
|
129
|
+
// ---------------------------------------------------------------------------
|
|
130
|
+
|
|
131
|
+
function makeLoggerAdapter(
|
|
132
|
+
log?: (msg: string, ctx?: Record<string, unknown>) => void,
|
|
133
|
+
): Logger {
|
|
134
|
+
const emit = (level: string) => (msg: string, ...args: unknown[]) => {
|
|
135
|
+
log?.(`[${level}] ${msg}${args.length ? " " + JSON.stringify(args) : ""}`);
|
|
136
|
+
};
|
|
137
|
+
return {
|
|
138
|
+
debug: emit("debug"),
|
|
139
|
+
info: emit("info"),
|
|
140
|
+
warn: emit("warn"),
|
|
141
|
+
error: emit("error"),
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// ---------------------------------------------------------------------------
|
|
146
|
+
// Lifecycle
|
|
147
|
+
// ---------------------------------------------------------------------------
|
|
148
|
+
|
|
149
|
+
export interface CreateClientOptions {
|
|
150
|
+
config: FeihanAccountConfig;
|
|
151
|
+
onMessage?: (event: FeihanMessageEvent, accountConfig: FeihanAccountConfig) => void;
|
|
152
|
+
log?: (msg: string, ctx?: Record<string, unknown>) => void;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Create and connect a Feihan SDK client for the given account.
|
|
157
|
+
* Stores it in the client map for later retrieval.
|
|
158
|
+
*/
|
|
159
|
+
export async function createClient(opts: CreateClientOptions): Promise<ManagedClient> {
|
|
160
|
+
const { config, onMessage, log } = opts;
|
|
161
|
+
const accountId = config.accountId;
|
|
162
|
+
|
|
163
|
+
// Tear down existing client for this account if any
|
|
164
|
+
if (clients.has(accountId)) {
|
|
165
|
+
await destroyClient(accountId);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
const sdkClient = await FeihanClient.create(
|
|
169
|
+
config.backendUrl,
|
|
170
|
+
config.appId,
|
|
171
|
+
config.appSecret,
|
|
172
|
+
{
|
|
173
|
+
enableEncryption: config.enableEncryption,
|
|
174
|
+
requestTimeout: config.requestTimeout,
|
|
175
|
+
logger: log ? makeLoggerAdapter(log) : undefined,
|
|
176
|
+
logLevel: log ? LoggerLevel.Debug : LoggerLevel.Info,
|
|
177
|
+
},
|
|
178
|
+
);
|
|
179
|
+
|
|
180
|
+
// Preheat warms the token and verifies connectivity
|
|
181
|
+
await sdkClient.preheat();
|
|
182
|
+
|
|
183
|
+
const managed: ManagedClient = { client: sdkClient, config, connectionState: "connected" };
|
|
184
|
+
|
|
185
|
+
// Subscribe to incoming messages if handler provided
|
|
186
|
+
if (onMessage) {
|
|
187
|
+
const handler = (sdkEvent: SdkMessageEvent) => {
|
|
188
|
+
const normalized = normalizeSdkEvent(sdkEvent);
|
|
189
|
+
if (normalized) {
|
|
190
|
+
onMessage(normalized, config);
|
|
191
|
+
}
|
|
192
|
+
};
|
|
193
|
+
|
|
194
|
+
// New SDK: onMessageReceive is synchronous, returns void.
|
|
195
|
+
// Store handler reference for offMessageReceive on teardown.
|
|
196
|
+
sdkClient.Im.v1.Message.Event.onMessageReceive(
|
|
197
|
+
handler as Parameters<typeof sdkClient.Im.v1.Message.Event.onMessageReceive>[0],
|
|
198
|
+
);
|
|
199
|
+
managed.eventHandler = handler;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
clients.set(accountId, managed);
|
|
203
|
+
return managed;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Destroy and disconnect a client by account ID.
|
|
208
|
+
*/
|
|
209
|
+
export async function destroyClient(accountId: string): Promise<void> {
|
|
210
|
+
const managed = clients.get(accountId);
|
|
211
|
+
if (!managed) return;
|
|
212
|
+
|
|
213
|
+
managed.connectionState = "disconnecting";
|
|
214
|
+
|
|
215
|
+
// Unsubscribe from events using offMessageReceive
|
|
216
|
+
if (managed.eventHandler) {
|
|
217
|
+
managed.client.Im.v1.Message.Event.offMessageReceive(
|
|
218
|
+
managed.eventHandler as Parameters<typeof managed.client.Im.v1.Message.Event.offMessageReceive>[0],
|
|
219
|
+
);
|
|
220
|
+
}
|
|
221
|
+
try {
|
|
222
|
+
await managed.client.close();
|
|
223
|
+
} catch {
|
|
224
|
+
// Best-effort close
|
|
225
|
+
}
|
|
226
|
+
clients.delete(accountId);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* Destroy all managed clients.
|
|
231
|
+
*/
|
|
232
|
+
export async function destroyAllClients(): Promise<void> {
|
|
233
|
+
const ids = [...clients.keys()];
|
|
234
|
+
await Promise.allSettled(ids.map((id) => destroyClient(id)));
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* Get a managed client by account ID. Falls back to the first available client.
|
|
239
|
+
*/
|
|
240
|
+
export function getClient(accountId?: string): ManagedClient | undefined {
|
|
241
|
+
if (accountId && clients.has(accountId)) return clients.get(accountId);
|
|
242
|
+
if (clients.size > 0) return clients.values().next().value as ManagedClient;
|
|
243
|
+
return undefined;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* Number of currently connected clients.
|
|
248
|
+
*/
|
|
249
|
+
export function clientCount(): number {
|
|
250
|
+
return clients.size;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* Get diagnostic state for all managed clients.
|
|
255
|
+
*/
|
|
256
|
+
export function getClientStates(): Array<{ accountId: string; connectionState: ConnectionState }> {
|
|
257
|
+
return [...clients.entries()].map(([id, m]) => ({
|
|
258
|
+
accountId: id,
|
|
259
|
+
connectionState: m.connectionState,
|
|
260
|
+
}));
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
// ---------------------------------------------------------------------------
|
|
264
|
+
// Event normalization — SDK snake_case event -> plugin camelCase
|
|
265
|
+
// ---------------------------------------------------------------------------
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* Convert a snake_case SDK event to the plugin's FeihanMessageEvent format.
|
|
269
|
+
* Returns null if the event is malformed.
|
|
270
|
+
*
|
|
271
|
+
* The new SDK delivers events with lowercase field names:
|
|
272
|
+
* { header, body: { message: { message_id, sender_id: UserId, ... } } }
|
|
273
|
+
*
|
|
274
|
+
* sender_id is now a UserId object { user_id, union_user_id, open_user_id }
|
|
275
|
+
* in the new SDK, but we keep backward compat with string for safety.
|
|
276
|
+
*/
|
|
277
|
+
export function normalizeSdkEvent(sdk: SdkMessageEvent): FeihanMessageEvent | null {
|
|
278
|
+
const msg = sdk.body?.message;
|
|
279
|
+
if (!msg) return null;
|
|
280
|
+
|
|
281
|
+
// Extract user ID from sender_id — may be UserId object or legacy string
|
|
282
|
+
const senderId = msg.sender_id;
|
|
283
|
+
let userId = "";
|
|
284
|
+
if (typeof senderId === "string") {
|
|
285
|
+
userId = senderId;
|
|
286
|
+
} else if (senderId && typeof senderId === "object") {
|
|
287
|
+
userId =
|
|
288
|
+
senderId.user_id ??
|
|
289
|
+
senderId.open_user_id ??
|
|
290
|
+
senderId.union_user_id ??
|
|
291
|
+
"";
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
// Extract mention user IDs from the new SDK's text mention format
|
|
295
|
+
const mentionUsers = (msg.mention_user_list ?? []).map((u) => ({
|
|
296
|
+
userId: u.user_id?.user_id ?? u.user_id?.open_user_id ?? u.user_id?.union_user_id ?? "",
|
|
297
|
+
}));
|
|
298
|
+
|
|
299
|
+
// message_created_at may be Int64 (string) in the new SDK
|
|
300
|
+
const createdAt =
|
|
301
|
+
typeof msg.message_created_at === "string"
|
|
302
|
+
? parseInt(msg.message_created_at, 10) || Date.now()
|
|
303
|
+
: msg.message_created_at ?? Date.now();
|
|
304
|
+
|
|
305
|
+
return {
|
|
306
|
+
message: {
|
|
307
|
+
messageId: msg.message_id ?? "",
|
|
308
|
+
messageType: msg.message_type ?? "",
|
|
309
|
+
messageContent: msg.message_content,
|
|
310
|
+
chatId: msg.chat_id ?? "",
|
|
311
|
+
chatType: msg.chat_type ?? "direct",
|
|
312
|
+
sender: {
|
|
313
|
+
userId,
|
|
314
|
+
},
|
|
315
|
+
createdAt,
|
|
316
|
+
mentionUserList: mentionUsers,
|
|
317
|
+
},
|
|
318
|
+
};
|
|
319
|
+
}
|