@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
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
// Copyright (c) 2026 上海飞函安全科技有限公司 (Shanghai Feihan Security Technology Co., Ltd.)
|
|
2
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Outbound message delivery — send text/typing/read to Feihan chats.
|
|
6
|
+
*
|
|
7
|
+
* Pipeline: normalize target -> validate account/client -> send -> map errors
|
|
8
|
+
*
|
|
9
|
+
* Text-only for now; media/card/file delivery is follow-up (task 08).
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import {
|
|
13
|
+
getClient,
|
|
14
|
+
isAuthError,
|
|
15
|
+
MessageType_TEXT,
|
|
16
|
+
type ManagedClient,
|
|
17
|
+
type SendMessageReq,
|
|
18
|
+
} from "../core/feihan-client.js";
|
|
19
|
+
|
|
20
|
+
// ---------------------------------------------------------------------------
|
|
21
|
+
// Send text
|
|
22
|
+
// ---------------------------------------------------------------------------
|
|
23
|
+
|
|
24
|
+
export interface SendTextResult {
|
|
25
|
+
ok: boolean;
|
|
26
|
+
messageId?: string;
|
|
27
|
+
error?: Error;
|
|
28
|
+
provider?: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Send a text message to a Feihan chat.
|
|
33
|
+
*
|
|
34
|
+
* On auth error (code 40000006), forces a token refresh via preheat() and
|
|
35
|
+
* retries once. This handles the SDK's token-refresh edge case without
|
|
36
|
+
* requiring a gateway restart.
|
|
37
|
+
*/
|
|
38
|
+
export async function sendText(
|
|
39
|
+
chatId: string,
|
|
40
|
+
text: string,
|
|
41
|
+
accountId?: string,
|
|
42
|
+
replyMessageId?: string,
|
|
43
|
+
logWarn?: (msg: string) => void,
|
|
44
|
+
): Promise<SendTextResult> {
|
|
45
|
+
const managed = getClient(accountId);
|
|
46
|
+
if (!managed) {
|
|
47
|
+
return {
|
|
48
|
+
ok: false,
|
|
49
|
+
error: new Error(
|
|
50
|
+
`[feihan] no connected client for account=${accountId ?? "default"}`,
|
|
51
|
+
),
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const req: SendMessageReq = {
|
|
56
|
+
chat_id: chatId,
|
|
57
|
+
message_type: MessageType_TEXT,
|
|
58
|
+
message_content: {
|
|
59
|
+
text: { content: text },
|
|
60
|
+
},
|
|
61
|
+
reply_message_id: replyMessageId,
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
try {
|
|
65
|
+
const resp = await managed.client.Im.v1.Message.sendMessage(req);
|
|
66
|
+
return { ok: true, messageId: resp.message_id, provider: "feihan" };
|
|
67
|
+
} catch (err) {
|
|
68
|
+
if (!isAuthError(err)) {
|
|
69
|
+
return {
|
|
70
|
+
ok: false,
|
|
71
|
+
error: new Error(
|
|
72
|
+
`[feihan] send failed for chat=${chatId}: ${err instanceof Error ? err.message : String(err)}`,
|
|
73
|
+
),
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Auth token expired despite SDK auto-refresh — force refresh and retry once.
|
|
78
|
+
// This avoids requiring a gateway restart when the SDK's background token
|
|
79
|
+
// refresh hits an edge case (time sync drift, swallowed fetch failure, etc.).
|
|
80
|
+
logWarn?.(
|
|
81
|
+
`[feihan] auth error on send (chat=${chatId}, account=${accountId ?? "default"}) — refreshing token and retrying`,
|
|
82
|
+
);
|
|
83
|
+
|
|
84
|
+
try {
|
|
85
|
+
await managed.client.preheat();
|
|
86
|
+
const resp = await managed.client.Im.v1.Message.sendMessage(req);
|
|
87
|
+
logWarn?.(
|
|
88
|
+
`[feihan] auth retry succeeded (chat=${chatId}, account=${accountId ?? "default"})`,
|
|
89
|
+
);
|
|
90
|
+
return { ok: true, messageId: resp.message_id, provider: "feihan" };
|
|
91
|
+
} catch (retryErr) {
|
|
92
|
+
return {
|
|
93
|
+
ok: false,
|
|
94
|
+
error: new Error(
|
|
95
|
+
`[feihan] send failed after auth retry for chat=${chatId}: ${retryErr instanceof Error ? retryErr.message : String(retryErr)}`,
|
|
96
|
+
),
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// ---------------------------------------------------------------------------
|
|
103
|
+
// Typing indicator
|
|
104
|
+
// ---------------------------------------------------------------------------
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Set typing indicator in a chat. Feihan typing lasts ~5s.
|
|
108
|
+
*/
|
|
109
|
+
export async function setTyping(
|
|
110
|
+
chatId: string,
|
|
111
|
+
accountId?: string,
|
|
112
|
+
): Promise<void> {
|
|
113
|
+
const managed = getClient(accountId);
|
|
114
|
+
if (!managed) return;
|
|
115
|
+
try {
|
|
116
|
+
await managed.client.Im.v1.Chat.createTyping({ chat_id: chatId });
|
|
117
|
+
} catch {
|
|
118
|
+
// Typing is best-effort — don't fail the message flow
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Clear typing indicator.
|
|
124
|
+
*/
|
|
125
|
+
export async function clearTyping(
|
|
126
|
+
chatId: string,
|
|
127
|
+
accountId?: string,
|
|
128
|
+
): Promise<void> {
|
|
129
|
+
const managed = getClient(accountId);
|
|
130
|
+
if (!managed) return;
|
|
131
|
+
try {
|
|
132
|
+
await managed.client.Im.v1.Chat.deleteTyping({ chat_id: chatId });
|
|
133
|
+
} catch {
|
|
134
|
+
// Best-effort
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// ---------------------------------------------------------------------------
|
|
139
|
+
// Read receipt
|
|
140
|
+
// ---------------------------------------------------------------------------
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Mark a message as read.
|
|
144
|
+
*/
|
|
145
|
+
export async function readMessage(
|
|
146
|
+
messageId: string,
|
|
147
|
+
accountId?: string,
|
|
148
|
+
): Promise<void> {
|
|
149
|
+
const managed = getClient(accountId);
|
|
150
|
+
if (!managed) return;
|
|
151
|
+
try {
|
|
152
|
+
await managed.client.Im.v1.Message.readMessage({ message_id: messageId });
|
|
153
|
+
} catch {
|
|
154
|
+
// Best-effort
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// ---------------------------------------------------------------------------
|
|
159
|
+
// Delivery callback for inbound dispatch
|
|
160
|
+
// ---------------------------------------------------------------------------
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Create a deliver function scoped to an account, suitable for passing
|
|
164
|
+
* to processInboundMessage's InboundDispatchOptions.
|
|
165
|
+
*/
|
|
166
|
+
export function makeDeliver(
|
|
167
|
+
accountId?: string,
|
|
168
|
+
logWarn?: (msg: string) => void,
|
|
169
|
+
): (chatId: string, text: string) => Promise<void> {
|
|
170
|
+
return async (chatId: string, text: string) => {
|
|
171
|
+
const result = await sendText(chatId, text, accountId, undefined, logWarn);
|
|
172
|
+
if (!result.ok) {
|
|
173
|
+
throw result.error ?? new Error("[feihan] send failed");
|
|
174
|
+
}
|
|
175
|
+
};
|
|
176
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
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 { parseTarget } from "./targets.js";
|
|
6
|
+
|
|
7
|
+
describe("parseTarget", () => {
|
|
8
|
+
// --- Happy paths ---
|
|
9
|
+
|
|
10
|
+
it('parses "user:<id>"', () => {
|
|
11
|
+
expect(parseTarget("user:abc123")).toEqual({ kind: "user", id: "abc123" });
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
it('parses "chat:<id>"', () => {
|
|
15
|
+
expect(parseTarget("chat:oc_xyz")).toEqual({ kind: "chat", id: "oc_xyz" });
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it('strips "feihan:" prefix and parses "chat:<id>"', () => {
|
|
19
|
+
expect(parseTarget("feihan:chat:oc_xyz")).toEqual({
|
|
20
|
+
kind: "chat",
|
|
21
|
+
id: "oc_xyz",
|
|
22
|
+
});
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it('strips "feihan:" prefix and parses "user:<id>"', () => {
|
|
26
|
+
expect(parseTarget("feihan:user:abc")).toEqual({
|
|
27
|
+
kind: "user",
|
|
28
|
+
id: "abc",
|
|
29
|
+
});
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it("strips feihan: prefix case-insensitively", () => {
|
|
33
|
+
expect(parseTarget("FEIHAN:chat:oc_1")).toEqual({
|
|
34
|
+
kind: "chat",
|
|
35
|
+
id: "oc_1",
|
|
36
|
+
});
|
|
37
|
+
expect(parseTarget("Feihan:user:u1")).toEqual({ kind: "user", id: "u1" });
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it("defaults bare ID to chat", () => {
|
|
41
|
+
expect(parseTarget("oc_abc123")).toEqual({
|
|
42
|
+
kind: "chat",
|
|
43
|
+
id: "oc_abc123",
|
|
44
|
+
});
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it("defaults bare numeric ID to chat", () => {
|
|
48
|
+
expect(parseTarget("83870344313569283")).toEqual({
|
|
49
|
+
kind: "chat",
|
|
50
|
+
id: "83870344313569283",
|
|
51
|
+
});
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it("trims whitespace", () => {
|
|
55
|
+
expect(parseTarget(" chat:oc_1 ")).toEqual({
|
|
56
|
+
kind: "chat",
|
|
57
|
+
id: "oc_1",
|
|
58
|
+
});
|
|
59
|
+
expect(parseTarget(" user:abc ")).toEqual({ kind: "user", id: "abc" });
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
// --- Null/invalid cases ---
|
|
63
|
+
|
|
64
|
+
it("returns null for undefined", () => {
|
|
65
|
+
expect(parseTarget(undefined)).toBeNull();
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it("returns null for empty string", () => {
|
|
69
|
+
expect(parseTarget("")).toBeNull();
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it("returns null for whitespace-only", () => {
|
|
73
|
+
expect(parseTarget(" ")).toBeNull();
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it('returns null for "user:" with no ID', () => {
|
|
77
|
+
expect(parseTarget("user:")).toBeNull();
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it('returns null for "chat:" with no ID', () => {
|
|
81
|
+
expect(parseTarget("chat:")).toBeNull();
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it('returns null for "user: " (whitespace-only ID)', () => {
|
|
85
|
+
expect(parseTarget("user: ")).toBeNull();
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it('returns null for "feihan:" with nothing after', () => {
|
|
89
|
+
expect(parseTarget("feihan:")).toBeNull();
|
|
90
|
+
});
|
|
91
|
+
});
|
package/src/targets.ts
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
// Copyright (c) 2026 上海飞函安全科技有限公司 (Shanghai Feihan Security Technology Co., Ltd.)
|
|
2
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Target parsing & validation for the --to argument.
|
|
6
|
+
*
|
|
7
|
+
* Accepted formats:
|
|
8
|
+
* "chat:oc_abc123" -> { kind: "chat", id: "oc_abc123" }
|
|
9
|
+
* "user:83870344313569283" -> { kind: "user", id: "83870344313569283" }
|
|
10
|
+
* "feihan:chat:oc_abc123" -> { kind: "chat", id: "oc_abc123" } (prefix stripped)
|
|
11
|
+
* "oc_abc123" -> { kind: "chat", id: "oc_abc123" } (bare ID defaults to chat)
|
|
12
|
+
*
|
|
13
|
+
* Returns null for empty, whitespace-only, or malformed targets (e.g. "user:" with no ID).
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import type { ParsedTarget } from "./types.js";
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Parse a raw --to string into a structured target.
|
|
20
|
+
* Returns null if the input is missing, empty, or has no usable ID.
|
|
21
|
+
*/
|
|
22
|
+
export function parseTarget(to?: string): ParsedTarget | null {
|
|
23
|
+
const raw = String(to ?? "").trim();
|
|
24
|
+
if (!raw) return null;
|
|
25
|
+
|
|
26
|
+
// Strip optional "feihan:" channel prefix (case-insensitive)
|
|
27
|
+
const stripped = raw.replace(/^feihan:/i, "");
|
|
28
|
+
|
|
29
|
+
if (stripped.startsWith("user:")) {
|
|
30
|
+
const id = stripped.slice("user:".length).trim();
|
|
31
|
+
return id ? { kind: "user", id } : null;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
if (stripped.startsWith("chat:")) {
|
|
35
|
+
const id = stripped.slice("chat:".length).trim();
|
|
36
|
+
return id ? { kind: "chat", id } : null;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Bare ID — default to chat (Feihan SDK uses ChatId for sending)
|
|
40
|
+
return stripped ? { kind: "chat", id: stripped } : null;
|
|
41
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
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
|
+
|
|
6
|
+
describe("project setup", () => {
|
|
7
|
+
it("builds and runs tests", () => {
|
|
8
|
+
expect(true).toBe(true);
|
|
9
|
+
});
|
|
10
|
+
});
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
// Copyright (c) 2026 上海飞函安全科技有限公司 (Shanghai Feihan Security Technology Co., Ltd.)
|
|
2
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
|
|
4
|
+
// --- Plugin Config Types ---
|
|
5
|
+
|
|
6
|
+
export interface FeihanAccountConfig {
|
|
7
|
+
accountId: string;
|
|
8
|
+
enabled: boolean;
|
|
9
|
+
appId: string;
|
|
10
|
+
appSecret: string;
|
|
11
|
+
backendUrl: string;
|
|
12
|
+
enableEncryption: boolean;
|
|
13
|
+
requestTimeout: number;
|
|
14
|
+
requireMention: boolean;
|
|
15
|
+
botUserId?: string;
|
|
16
|
+
inboundWhitelist: string[];
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface FeihanRawAccountConfig {
|
|
20
|
+
appId?: string;
|
|
21
|
+
appSecret?: string;
|
|
22
|
+
backendUrl?: string;
|
|
23
|
+
enableEncryption?: boolean;
|
|
24
|
+
requestTimeout?: number;
|
|
25
|
+
requireMention?: boolean;
|
|
26
|
+
enabled?: boolean;
|
|
27
|
+
botUserId?: string;
|
|
28
|
+
inboundWhitelist?: string[];
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface FeihanChannelConfig {
|
|
32
|
+
appId?: string;
|
|
33
|
+
appSecret?: string;
|
|
34
|
+
backendUrl?: string;
|
|
35
|
+
enableEncryption?: boolean;
|
|
36
|
+
requestTimeout?: number;
|
|
37
|
+
requireMention?: boolean;
|
|
38
|
+
enabled?: boolean;
|
|
39
|
+
botUserId?: string;
|
|
40
|
+
inboundWhitelist?: string[];
|
|
41
|
+
accounts?: Record<string, FeihanRawAccountConfig>;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// --- Feihan Event Types ---
|
|
45
|
+
|
|
46
|
+
export interface FeihanUserId {
|
|
47
|
+
userId: string;
|
|
48
|
+
unionUserId?: string;
|
|
49
|
+
openUserId?: string;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export interface FeihanMessage {
|
|
53
|
+
messageId: string;
|
|
54
|
+
messageType: string;
|
|
55
|
+
messageContent: unknown;
|
|
56
|
+
chatId: string;
|
|
57
|
+
chatType: string;
|
|
58
|
+
sender: FeihanUserId;
|
|
59
|
+
createdAt: number;
|
|
60
|
+
mentionUserList?: FeihanUserId[];
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export interface FeihanMessageEvent {
|
|
64
|
+
message: FeihanMessage;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// --- Target Types ---
|
|
68
|
+
|
|
69
|
+
export interface ParsedTarget {
|
|
70
|
+
kind: "user" | "chat";
|
|
71
|
+
id: string;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// --- Connection Types ---
|
|
75
|
+
|
|
76
|
+
export type ConnectionState =
|
|
77
|
+
| "disconnected"
|
|
78
|
+
| "connecting"
|
|
79
|
+
| "connected"
|
|
80
|
+
| "disconnecting";
|
|
81
|
+
|
|
82
|
+
// --- OpenClaw Plugin API (minimal type surface) ---
|
|
83
|
+
|
|
84
|
+
export interface PluginApi {
|
|
85
|
+
registerChannel(opts: { plugin: unknown }): void;
|
|
86
|
+
registerService(opts: {
|
|
87
|
+
id: string;
|
|
88
|
+
start: () => Promise<void>;
|
|
89
|
+
stop: () => Promise<void>;
|
|
90
|
+
}): void;
|
|
91
|
+
config: { channels?: { feihan?: FeihanChannelConfig } } & Record<
|
|
92
|
+
string,
|
|
93
|
+
unknown
|
|
94
|
+
>;
|
|
95
|
+
runtime: {
|
|
96
|
+
channel: {
|
|
97
|
+
reply: {
|
|
98
|
+
dispatchReplyWithBufferedBlockDispatcher: (opts: unknown) => Promise<void>;
|
|
99
|
+
};
|
|
100
|
+
session: {
|
|
101
|
+
recordInboundSession: (opts: unknown) => Promise<void>;
|
|
102
|
+
resolveStorePath?: (store: unknown, opts: unknown) => string;
|
|
103
|
+
};
|
|
104
|
+
routing: {
|
|
105
|
+
resolveAgentRoute: (opts: unknown) => unknown;
|
|
106
|
+
};
|
|
107
|
+
};
|
|
108
|
+
};
|
|
109
|
+
logger?: {
|
|
110
|
+
info: (msg: string) => void;
|
|
111
|
+
warn: (msg: string) => void;
|
|
112
|
+
error: (msg: string) => void;
|
|
113
|
+
debug: (msg: string) => void;
|
|
114
|
+
};
|
|
115
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
// Copyright (c) 2026 上海飞函安全科技有限公司 (Shanghai Feihan Security Technology Co., Ltd.)
|
|
2
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Re-export types from @feihan-im/sdk used across the plugin.
|
|
6
|
+
*
|
|
7
|
+
* The published SDK ships its own type declarations. This file exists
|
|
8
|
+
* only to keep the rest of the plugin importing from a single local
|
|
9
|
+
* barrel, making future SDK swaps cheaper.
|
|
10
|
+
*/
|
|
11
|
+
export type {
|
|
12
|
+
FeihanClient,
|
|
13
|
+
FeihanClientOptions,
|
|
14
|
+
} from "@feihan-im/sdk";
|
|
15
|
+
|
|
16
|
+
export {
|
|
17
|
+
LoggerLevel,
|
|
18
|
+
} from "@feihan-im/sdk";
|
|
19
|
+
|
|
20
|
+
export type {
|
|
21
|
+
Logger,
|
|
22
|
+
EventHeader,
|
|
23
|
+
} from "@feihan-im/sdk";
|