@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.
@@ -0,0 +1,164 @@
1
+ // Copyright (c) 2026 上海飞函安全科技有限公司 (Shanghai Feihan Security Technology Co., Ltd.)
2
+ // SPDX-License-Identifier: Apache-2.0
3
+
4
+ import { describe, it, expect, vi, beforeEach } from "vitest";
5
+ import register from "./index.js";
6
+ import type { PluginApi } from "./types.js";
7
+
8
+ vi.mock("./core/feihan-client.js", () => ({
9
+ createClient: vi.fn(),
10
+ destroyAllClients: vi.fn().mockResolvedValue(undefined),
11
+ clientCount: vi.fn().mockReturnValue(0),
12
+ }));
13
+
14
+ vi.mock("./messaging/outbound.js", () => ({
15
+ makeDeliver: vi.fn().mockReturnValue(vi.fn()),
16
+ setTyping: vi.fn().mockResolvedValue(undefined),
17
+ clearTyping: vi.fn().mockResolvedValue(undefined),
18
+ readMessage: vi.fn().mockResolvedValue(undefined),
19
+ }));
20
+
21
+ import { createClient, clientCount } from "./core/feihan-client.js";
22
+
23
+ function createMockApi(config: unknown = {}): PluginApi {
24
+ return {
25
+ registerChannel: vi.fn(),
26
+ registerService: vi.fn(),
27
+ config: config as PluginApi["config"],
28
+ runtime: {
29
+ channel: {
30
+ reply: { dispatchReplyWithBufferedBlockDispatcher: vi.fn() },
31
+ session: { recordInboundSession: vi.fn() },
32
+ routing: { resolveAgentRoute: vi.fn() },
33
+ },
34
+ },
35
+ logger: {
36
+ info: vi.fn(),
37
+ warn: vi.fn(),
38
+ error: vi.fn(),
39
+ debug: vi.fn(),
40
+ },
41
+ };
42
+ }
43
+
44
+ function extractStartFn(api: PluginApi): () => Promise<void> {
45
+ const call = (api.registerService as ReturnType<typeof vi.fn>).mock.calls[0];
46
+ return call[0].start;
47
+ }
48
+
49
+ describe("register", () => {
50
+ beforeEach(() => {
51
+ vi.mocked(clientCount).mockReturnValue(0);
52
+ vi.mocked(createClient).mockResolvedValue({ client: {}, config: {} } as any);
53
+ });
54
+
55
+ it("registers channel and service", () => {
56
+ const api = createMockApi();
57
+ register(api);
58
+
59
+ expect(api.registerChannel).toHaveBeenCalledOnce();
60
+ expect(api.registerChannel).toHaveBeenCalledWith(
61
+ expect.objectContaining({ plugin: expect.objectContaining({ id: "feihan" }) }),
62
+ );
63
+
64
+ expect(api.registerService).toHaveBeenCalledOnce();
65
+ expect(api.registerService).toHaveBeenCalledWith(
66
+ expect.objectContaining({ id: "feihan-sdk" }),
67
+ );
68
+ });
69
+
70
+ it("logs plugin registration", () => {
71
+ const api = createMockApi();
72
+ register(api);
73
+ expect(api.logger!.info).toHaveBeenCalledWith("[feihan] plugin registered");
74
+ });
75
+ });
76
+
77
+ describe("service start — config validation", () => {
78
+ beforeEach(() => {
79
+ vi.clearAllMocks();
80
+ vi.mocked(clientCount).mockReturnValue(0);
81
+ vi.mocked(createClient).mockResolvedValue({ client: {}, config: {} } as any);
82
+ });
83
+
84
+ it("skips account with empty appId and logs warning", async () => {
85
+ const api = createMockApi({
86
+ channels: {
87
+ feihan: {
88
+ accounts: {
89
+ broken: { appId: "", appSecret: "s", backendUrl: "https://x.io", enabled: true },
90
+ },
91
+ },
92
+ },
93
+ });
94
+ register(api);
95
+ await extractStartFn(api)();
96
+
97
+ expect(api.logger!.warn).toHaveBeenCalledWith(
98
+ expect.stringContaining("skipping account=broken"),
99
+ );
100
+ expect(api.logger!.warn).toHaveBeenCalledWith(
101
+ expect.stringContaining("appId is required"),
102
+ );
103
+ expect(createClient).not.toHaveBeenCalled();
104
+ });
105
+
106
+ it("skips account with invalid backendUrl scheme and logs warning", async () => {
107
+ const api = createMockApi({
108
+ channels: {
109
+ feihan: {
110
+ accounts: {
111
+ bad: { appId: "a", appSecret: "s", backendUrl: "ftp://bad.url", enabled: true },
112
+ },
113
+ },
114
+ },
115
+ });
116
+ register(api);
117
+ await extractStartFn(api)();
118
+
119
+ expect(api.logger!.warn).toHaveBeenCalledWith(
120
+ expect.stringContaining("must start with http://"),
121
+ );
122
+ expect(createClient).not.toHaveBeenCalled();
123
+ });
124
+
125
+ it("connects valid accounts normally", async () => {
126
+ const api = createMockApi({
127
+ channels: {
128
+ feihan: { appId: "a", appSecret: "s", backendUrl: "https://ok.io" },
129
+ },
130
+ });
131
+ register(api);
132
+ await extractStartFn(api)();
133
+
134
+ expect(createClient).toHaveBeenCalledOnce();
135
+ expect(api.logger!.info).toHaveBeenCalledWith(
136
+ expect.stringContaining("connected"),
137
+ );
138
+ });
139
+
140
+ it("skips invalid account but connects valid one in multi-account config", async () => {
141
+ const api = createMockApi({
142
+ channels: {
143
+ feihan: {
144
+ accounts: {
145
+ bad: { appId: "", appSecret: "s", backendUrl: "https://x.io", enabled: true },
146
+ good: { appId: "a", appSecret: "s", backendUrl: "https://x.io", enabled: true },
147
+ },
148
+ },
149
+ },
150
+ });
151
+ register(api);
152
+ await extractStartFn(api)();
153
+
154
+ // bad account skipped with warning
155
+ expect(api.logger!.warn).toHaveBeenCalledWith(
156
+ expect.stringContaining("skipping account=bad"),
157
+ );
158
+ // good account connected
159
+ expect(createClient).toHaveBeenCalledOnce();
160
+ expect(api.logger!.info).toHaveBeenCalledWith(
161
+ expect.stringContaining("account=good connected"),
162
+ );
163
+ });
164
+ });
package/src/index.ts ADDED
@@ -0,0 +1,112 @@
1
+ // Copyright (c) 2026 上海飞函安全科技有限公司 (Shanghai Feihan Security Technology Co., Ltd.)
2
+ // SPDX-License-Identifier: Apache-2.0
3
+
4
+ import { feihanPlugin } from "./channel.js";
5
+ import { listEnabledAccountConfigs, validateAccountConfig } from "./config.js";
6
+ import {
7
+ createClient,
8
+ destroyAllClients,
9
+ clientCount,
10
+ } from "./core/feihan-client.js";
11
+ import { processInboundMessage } from "./messaging/inbound.js";
12
+ import { makeDeliver, setTyping, clearTyping, readMessage } from "./messaging/outbound.js";
13
+ import type { PluginApi, FeihanAccountConfig, FeihanMessageEvent } from "./types.js";
14
+
15
+ export default function register(api: PluginApi): void {
16
+ api.registerChannel({ plugin: feihanPlugin });
17
+
18
+ api.registerService({
19
+ id: "feihan-sdk",
20
+ start: async () => {
21
+ if (clientCount() > 0) return;
22
+
23
+ const accounts = listEnabledAccountConfigs(api.config);
24
+ if (accounts.length === 0) {
25
+ api.logger?.warn("[feihan] no enabled account config found — service idle");
26
+ return;
27
+ }
28
+
29
+ for (const account of accounts) {
30
+ const errors = validateAccountConfig(account);
31
+ if (errors.length > 0) {
32
+ api.logger?.warn(
33
+ `[feihan] skipping account=${account.accountId}: ${errors.map((e) => e.message).join("; ")}`,
34
+ );
35
+ continue;
36
+ }
37
+
38
+ try {
39
+ await startAccount(api, account);
40
+ api.logger?.info(
41
+ `[feihan] account=${account.accountId} connected (appId=${account.appId})`,
42
+ );
43
+ } catch (err) {
44
+ api.logger?.error(
45
+ `[feihan] account=${account.accountId} failed to start: ${err instanceof Error ? err.message : String(err)}`,
46
+ );
47
+ }
48
+ }
49
+
50
+ api.logger?.info(`[feihan] service started with ${clientCount()} account(s)`);
51
+ },
52
+ stop: async () => {
53
+ await destroyAllClients();
54
+ api.logger?.info("[feihan] service stopped — all clients disconnected");
55
+ },
56
+ });
57
+
58
+ api.logger?.info("[feihan] plugin registered");
59
+ }
60
+
61
+ /**
62
+ * Start a single account — create client, subscribe to inbound events.
63
+ */
64
+ async function startAccount(
65
+ api: PluginApi,
66
+ account: FeihanAccountConfig,
67
+ ): Promise<void> {
68
+ const deliver = makeDeliver(account.accountId, (msg) => api.logger?.warn(msg));
69
+
70
+ await createClient({
71
+ config: account,
72
+ log: (msg: string) => api.logger?.debug(msg),
73
+ onMessage: (event: FeihanMessageEvent, accountConfig: FeihanAccountConfig) => {
74
+ // Fire-and-forget: process inbound with typing indicator
75
+ handleInbound(api, accountConfig, event, deliver).catch((err) => {
76
+ api.logger?.error(
77
+ `[feihan] unhandled inbound error for account=${accountConfig.accountId}: ${err}`,
78
+ );
79
+ });
80
+ },
81
+ });
82
+ }
83
+
84
+ /**
85
+ * Handle a single inbound message with typing indicator lifecycle.
86
+ */
87
+ async function handleInbound(
88
+ api: PluginApi,
89
+ account: FeihanAccountConfig,
90
+ event: FeihanMessageEvent,
91
+ deliver: (chatId: string, text: string) => Promise<void>,
92
+ ): Promise<void> {
93
+ const chatId = event.message?.chatId;
94
+ const messageId = event.message?.messageId;
95
+
96
+ // Set typing indicator and mark message as read before processing
97
+ if (chatId) {
98
+ setTyping(chatId, account.accountId).catch(() => {});
99
+ }
100
+ if (messageId) {
101
+ readMessage(messageId, account.accountId).catch(() => {});
102
+ }
103
+
104
+ try {
105
+ await processInboundMessage(api, account, event, { deliver });
106
+ } finally {
107
+ // Clear typing after dispatch completes
108
+ if (chatId) {
109
+ clearTyping(chatId, account.accountId).catch(() => {});
110
+ }
111
+ }
112
+ }