libp2p-mesh 2026.5.13 → 2026.5.14
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/README.md +61 -23
- package/api.ts +1 -1
- package/dist/api.d.ts +1 -1
- package/dist/index.d.ts +0 -1
- package/dist/index.js +60 -24
- package/dist/runtime-setter-api.d.ts +4 -0
- package/dist/runtime-setter-api.js +19 -0
- package/dist/src/agent-tools.d.ts +63 -24
- package/dist/src/agent-tools.js +69 -34
- package/dist/src/channel.d.ts +1 -0
- package/dist/src/channel.js +20 -4
- package/dist/src/dht-registry.d.ts +38 -0
- package/dist/src/dht-registry.js +80 -0
- package/dist/src/inbound.d.ts +0 -3
- package/dist/src/inbound.js +29 -14
- package/dist/src/instance-id.d.ts +53 -0
- package/dist/src/instance-id.js +156 -0
- package/dist/src/mesh.js +310 -23
- package/dist/src/plugin.d.ts +1 -2
- package/dist/src/plugin.js +18 -30
- package/dist/src/types.d.ts +87 -0
- package/index.ts +60 -24
- package/openclaw.plugin.json +72 -33
- package/package.json +19 -7
- package/src/agent-tools.ts +69 -35
- package/src/channel.ts +25 -4
- package/src/dht-registry.ts +105 -0
- package/src/inbound.ts +35 -18
- package/src/instance-id.ts +221 -0
- package/src/mesh.ts +368 -27
- package/src/plugin.ts +25 -36
- package/src/types.ts +95 -0
- package/src/agent-tools-feishu.test.ts +0 -68
- package/src/config-schema.test.ts +0 -63
- package/src/feishu-channel.test.ts +0 -191
- package/src/feishu-channel.ts +0 -253
- package/src/feishu-client.test.ts +0 -303
- package/src/feishu-client.ts +0 -178
- package/src/feishu-e2e.test.ts +0 -90
- package/src/feishu-types.test.ts +0 -125
- package/src/feishu-types.ts +0 -51
- package/src/inbound-feishu.test.ts +0 -91
- package/src/index.ts +0 -1
- package/src/plugin-registration.test.ts +0 -60
package/src/types.ts
CHANGED
|
@@ -1,3 +1,22 @@
|
|
|
1
|
+
export interface InstanceIdentity {
|
|
2
|
+
/** Full InstanceID string, e.g. "alice-mac@AQIDBAUGBweI.7a3f9e2b" */
|
|
3
|
+
id: string;
|
|
4
|
+
/** Human-readable instance name */
|
|
5
|
+
name: string;
|
|
6
|
+
/** Base64url-encoded Ed25519 public key */
|
|
7
|
+
pubkey: string;
|
|
8
|
+
/** Hex SHA-256 binding hash of environment dimensions */
|
|
9
|
+
binding: string;
|
|
10
|
+
/** Components that contributed to the binding hash */
|
|
11
|
+
bindingComponents: {
|
|
12
|
+
username: string;
|
|
13
|
+
hostname: string;
|
|
14
|
+
platform: string;
|
|
15
|
+
};
|
|
16
|
+
/** Timestamp when the identity was created */
|
|
17
|
+
createdAt: number;
|
|
18
|
+
}
|
|
19
|
+
|
|
1
20
|
export interface P2PMessage {
|
|
2
21
|
id: string;
|
|
3
22
|
type: "direct" | "broadcast" | "agent-sync";
|
|
@@ -6,6 +25,12 @@ export interface P2PMessage {
|
|
|
6
25
|
topic?: string;
|
|
7
26
|
payload: string;
|
|
8
27
|
timestamp: number;
|
|
28
|
+
/** Instance identity of the sender (for cross-instance authentication) */
|
|
29
|
+
instanceId?: string;
|
|
30
|
+
/** Base64url-encoded Ed25519 public key of the sender (allows direct verification without DHT lookup) */
|
|
31
|
+
pubkey?: string;
|
|
32
|
+
/** Ed25519 signature of the message payload, verifiable with instance pubkey */
|
|
33
|
+
signature?: string;
|
|
9
34
|
}
|
|
10
35
|
|
|
11
36
|
export interface MeshConfig {
|
|
@@ -16,6 +41,70 @@ export interface MeshConfig {
|
|
|
16
41
|
enableAgentSync?: boolean;
|
|
17
42
|
enableWebSocket?: boolean;
|
|
18
43
|
peerIdPath?: string;
|
|
44
|
+
instanceName?: string;
|
|
45
|
+
/** Enable DHT for WAN peer discovery and pubkey registry (default: true when discovery=dht, false otherwise) */
|
|
46
|
+
enableDHT?: boolean;
|
|
47
|
+
|
|
48
|
+
// ---------------------------------------------------------------------
|
|
49
|
+
// NAT traversal (all opt-in, on by default; safe to leave at defaults
|
|
50
|
+
// when both peers already have a routable address)
|
|
51
|
+
// ---------------------------------------------------------------------
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Master switch for the NAT traversal stack. When `false` none of the
|
|
55
|
+
* NAT-related services are wired in, restoring the pre-2026.5.16
|
|
56
|
+
* behaviour. Default `true`.
|
|
57
|
+
*/
|
|
58
|
+
enableNATTraversal?: boolean;
|
|
59
|
+
/** Run the libp2p identify protocol (required by AutoNAT/DCUtR). Default `true` when NAT traversal is on. */
|
|
60
|
+
enableIdentify?: boolean;
|
|
61
|
+
/** AutoNAT detects whether we are reachable from the public internet. Default `true` when NAT traversal is on. */
|
|
62
|
+
enableAutoNAT?: boolean;
|
|
63
|
+
/** Attempt UPnP/PMP port mapping on the local gateway. Default `true` when NAT traversal is on. */
|
|
64
|
+
enableUPnP?: boolean;
|
|
65
|
+
/** Circuit Relay v2 transport — required to dial peers via a relay. Default `true` when NAT traversal is on. */
|
|
66
|
+
enableCircuitRelay?: boolean;
|
|
67
|
+
/**
|
|
68
|
+
* Act as a Circuit Relay v2 server for other peers. Default `false` —
|
|
69
|
+
* only enable on a node with a public, routable address (e.g. a cloud VM)
|
|
70
|
+
* because relayed traffic is forwarded through this process.
|
|
71
|
+
*/
|
|
72
|
+
enableCircuitRelayServer?: boolean;
|
|
73
|
+
/** Direct Connection Upgrade through Relay (hole punching). Default `true` when NAT traversal is on. */
|
|
74
|
+
enableDCUtR?: boolean;
|
|
75
|
+
/**
|
|
76
|
+
* Explicit relay multiaddrs to reserve a slot on. Each entry should be a
|
|
77
|
+
* full multiaddr ending in `/p2p/<peer-id>`. The node will keep a
|
|
78
|
+
* reservation open with each one so other peers can dial us through them.
|
|
79
|
+
*/
|
|
80
|
+
relayList?: string[];
|
|
81
|
+
/**
|
|
82
|
+
* Number of relays to auto-discover via content routing. Requires DHT.
|
|
83
|
+
* Default `0` (disabled). Set to e.g. `2` to look up public relays.
|
|
84
|
+
*/
|
|
85
|
+
discoverRelays?: number;
|
|
86
|
+
/**
|
|
87
|
+
* Multiaddrs to announce to the network on top of the auto-detected
|
|
88
|
+
* listen/observed addresses. Useful when running behind a known port
|
|
89
|
+
* forward where AutoNAT cannot probe (e.g. behind a cloud LB).
|
|
90
|
+
*/
|
|
91
|
+
announceAddrs?: string[];
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export interface NATTraversalStatus {
|
|
95
|
+
/** Which NAT-traversal services were wired in at start() */
|
|
96
|
+
enabled: {
|
|
97
|
+
identify: boolean;
|
|
98
|
+
autoNAT: boolean;
|
|
99
|
+
upnp: boolean;
|
|
100
|
+
circuitRelay: boolean;
|
|
101
|
+
circuitRelayServer: boolean;
|
|
102
|
+
dcutr: boolean;
|
|
103
|
+
};
|
|
104
|
+
/** Relay multiaddrs we have an active reservation on */
|
|
105
|
+
reservedRelays: string[];
|
|
106
|
+
/** Whether at least one circuit-relay address has been advertised as a listen address */
|
|
107
|
+
hasRelayedListenAddr: boolean;
|
|
19
108
|
}
|
|
20
109
|
|
|
21
110
|
export interface MeshNetwork {
|
|
@@ -27,6 +116,12 @@ export interface MeshNetwork {
|
|
|
27
116
|
subscribeToTopic(topic: string, handler: (msg: string) => void): Promise<void>;
|
|
28
117
|
getLocalPeerId(): string;
|
|
29
118
|
getConnectedPeers(): string[];
|
|
119
|
+
getMultiaddrs(): string[];
|
|
120
|
+
dial(multiaddr: string): Promise<void>;
|
|
121
|
+
/** Get the OpenClaw instance identity (lightweight BAID-inspired ID) */
|
|
122
|
+
getInstanceIdentity(): InstanceIdentity | undefined;
|
|
123
|
+
/** Inspect which NAT-traversal services are running and whether any relay reservations are active */
|
|
124
|
+
getNATStatus(): NATTraversalStatus;
|
|
30
125
|
}
|
|
31
126
|
|
|
32
127
|
export type MeshAccount = {
|
|
@@ -1,68 +0,0 @@
|
|
|
1
|
-
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
2
|
-
import { buildFeishuTools } from "./agent-tools.js";
|
|
3
|
-
|
|
4
|
-
describe("buildFeishuTools", () => {
|
|
5
|
-
it("should return empty array when feishuClient is null", () => {
|
|
6
|
-
const tools = buildFeishuTools(null);
|
|
7
|
-
expect(tools).toEqual([]);
|
|
8
|
-
});
|
|
9
|
-
|
|
10
|
-
it("should return array with one tool when feishuClient is provided", () => {
|
|
11
|
-
const mockClient = {
|
|
12
|
-
sendMessage: vi.fn(),
|
|
13
|
-
} as any;
|
|
14
|
-
const tools = buildFeishuTools(mockClient);
|
|
15
|
-
expect(tools).toHaveLength(1);
|
|
16
|
-
expect(tools[0].name).toBe("feishu_send_message");
|
|
17
|
-
});
|
|
18
|
-
|
|
19
|
-
describe("feishu_send_message tool", () => {
|
|
20
|
-
let tools: ReturnType<typeof buildFeishuTools>;
|
|
21
|
-
let mockClient: any;
|
|
22
|
-
|
|
23
|
-
beforeEach(() => {
|
|
24
|
-
mockClient = {
|
|
25
|
-
sendMessage: vi.fn(),
|
|
26
|
-
};
|
|
27
|
-
tools = buildFeishuTools(mockClient);
|
|
28
|
-
});
|
|
29
|
-
|
|
30
|
-
it("should require openId and message parameters", () => {
|
|
31
|
-
const tool = tools[0];
|
|
32
|
-
expect(tool.parameters.required).toEqual(["openId", "message"]);
|
|
33
|
-
});
|
|
34
|
-
|
|
35
|
-
it("should return success result on successful send", async () => {
|
|
36
|
-
mockClient.sendMessage.mockResolvedValue({
|
|
37
|
-
success: true,
|
|
38
|
-
messageId: "msg-789",
|
|
39
|
-
});
|
|
40
|
-
|
|
41
|
-
const result = await tools[0].execute("call-1", {
|
|
42
|
-
openId: "ou-test",
|
|
43
|
-
message: "hello",
|
|
44
|
-
});
|
|
45
|
-
|
|
46
|
-
expect(result.isError).toBeUndefined();
|
|
47
|
-
expect(result.details.sent).toBe(true);
|
|
48
|
-
expect(result.details.openId).toBe("ou-test");
|
|
49
|
-
expect(mockClient.sendMessage).toHaveBeenCalledWith("ou-test", "hello");
|
|
50
|
-
});
|
|
51
|
-
|
|
52
|
-
it("should return error result on failed send", async () => {
|
|
53
|
-
mockClient.sendMessage.mockResolvedValue({
|
|
54
|
-
success: false,
|
|
55
|
-
error: "rate limited",
|
|
56
|
-
});
|
|
57
|
-
|
|
58
|
-
const result = await tools[0].execute("call-1", {
|
|
59
|
-
openId: "ou-test",
|
|
60
|
-
message: "hello",
|
|
61
|
-
});
|
|
62
|
-
|
|
63
|
-
expect(result.isError).toBe(true);
|
|
64
|
-
expect(result.details.sent).toBe(false);
|
|
65
|
-
expect(result.details.error).toBe("rate limited");
|
|
66
|
-
});
|
|
67
|
-
});
|
|
68
|
-
});
|
|
@@ -1,63 +0,0 @@
|
|
|
1
|
-
import { describe, it, expect } from "vitest";
|
|
2
|
-
import { createLibp2pMeshConfigSchema } from "./index.js";
|
|
3
|
-
|
|
4
|
-
describe("createLibp2pMeshConfigSchema - Feishu config", () => {
|
|
5
|
-
const schema = createLibp2pMeshConfigSchema()!;
|
|
6
|
-
|
|
7
|
-
it("should include feishu in jsonSchema properties", () => {
|
|
8
|
-
const props = (schema as any).jsonSchema.properties as Record<string, any>;
|
|
9
|
-
expect(props).toHaveProperty("feishu");
|
|
10
|
-
});
|
|
11
|
-
|
|
12
|
-
it("feishu should be an object type with correct properties", () => {
|
|
13
|
-
const props = (schema as any).jsonSchema.properties as Record<string, any>;
|
|
14
|
-
const feishu = props.feishu;
|
|
15
|
-
expect(feishu.type).toBe("object");
|
|
16
|
-
expect(feishu.properties).toHaveProperty("appId");
|
|
17
|
-
expect(feishu.properties).toHaveProperty("appSecret");
|
|
18
|
-
expect(feishu.properties).toHaveProperty("webhookPort");
|
|
19
|
-
expect(feishu.properties).toHaveProperty("webhookPath");
|
|
20
|
-
});
|
|
21
|
-
|
|
22
|
-
it("feishu.webhookPort should default to 9222", () => {
|
|
23
|
-
const props = (schema as any).jsonSchema.properties as Record<string, any>;
|
|
24
|
-
expect(props.feishu.properties.webhookPort.default).toBe(9222);
|
|
25
|
-
});
|
|
26
|
-
|
|
27
|
-
it("feishu.webhookPath should default to /webhook/feishu", () => {
|
|
28
|
-
const props = (schema as any).jsonSchema.properties as Record<string, any>;
|
|
29
|
-
expect(props.feishu.properties.webhookPath.default).toBe("/webhook/feishu");
|
|
30
|
-
});
|
|
31
|
-
|
|
32
|
-
it("feishu.appId and appSecret should be string type without default", () => {
|
|
33
|
-
const props = (schema as any).jsonSchema.properties as Record<string, any>;
|
|
34
|
-
expect(props.feishu.properties.appId.type).toBe("string");
|
|
35
|
-
expect(props.feishu.properties.appSecret.type).toBe("string");
|
|
36
|
-
expect(props.feishu.properties.appId.default).toBeUndefined();
|
|
37
|
-
expect(props.feishu.properties.appSecret.default).toBeUndefined();
|
|
38
|
-
});
|
|
39
|
-
|
|
40
|
-
it("should parse config with feishu fields", () => {
|
|
41
|
-
const result = (schema as any).safeParse({
|
|
42
|
-
listenAddrs: ["/ip4/0.0.0.0/tcp/0"],
|
|
43
|
-
feishu: {
|
|
44
|
-
appId: "cli-123",
|
|
45
|
-
appSecret: "secret-456",
|
|
46
|
-
webhookPort: 9999,
|
|
47
|
-
webhookPath: "/custom/path",
|
|
48
|
-
},
|
|
49
|
-
});
|
|
50
|
-
expect(result.success).toBe(true);
|
|
51
|
-
if (result.success) {
|
|
52
|
-
expect(result.data.feishu.appId).toBe("cli-123");
|
|
53
|
-
expect(result.data.feishu.webhookPort).toBe(9999);
|
|
54
|
-
}
|
|
55
|
-
});
|
|
56
|
-
|
|
57
|
-
it("should parse config without feishu (feishu is optional)", () => {
|
|
58
|
-
const result = (schema as any).safeParse({
|
|
59
|
-
listenAddrs: ["/ip4/0.0.0.0/tcp/0"],
|
|
60
|
-
});
|
|
61
|
-
expect(result.success).toBe(true);
|
|
62
|
-
});
|
|
63
|
-
});
|
|
@@ -1,191 +0,0 @@
|
|
|
1
|
-
import { describe, it, expect, vi } from "vitest";
|
|
2
|
-
import { createFeishuChannel, handleFeishuWebhook } from "./feishu-channel.js";
|
|
3
|
-
import type { FeishuChannelConfig } from "./feishu-types.js";
|
|
4
|
-
import type { FeishuApiClient, SendResult } from "./feishu-client.js";
|
|
5
|
-
|
|
6
|
-
const MOCK_CONFIG: FeishuChannelConfig = {
|
|
7
|
-
appId: "test-app-id",
|
|
8
|
-
appSecret: "test-app-secret",
|
|
9
|
-
webhookPort: 9222,
|
|
10
|
-
webhookPath: "/webhook/feishu",
|
|
11
|
-
};
|
|
12
|
-
|
|
13
|
-
const MOCK_CLIENT: FeishuApiClient = {
|
|
14
|
-
isConfigured: () => true,
|
|
15
|
-
getTenantAccessToken: vi.fn().mockResolvedValue("t-valid"),
|
|
16
|
-
sendMessage: vi.fn().mockResolvedValue({ success: true, messageId: "msg-123" }),
|
|
17
|
-
} as unknown as FeishuApiClient;
|
|
18
|
-
|
|
19
|
-
describe("createFeishuChannel", () => {
|
|
20
|
-
it("should return a ChannelPlugin with correct base properties", () => {
|
|
21
|
-
const channel = createFeishuChannel(MOCK_CONFIG, {}, MOCK_CLIENT);
|
|
22
|
-
expect(channel.base.id).toBe("feishu");
|
|
23
|
-
expect((channel.base.meta as Record<string, string>).label).toBe("Feishu");
|
|
24
|
-
expect((channel.base.capabilities as Record<string, unknown>).chatTypes).toEqual(["direct"]);
|
|
25
|
-
});
|
|
26
|
-
|
|
27
|
-
it("should set targetResolver hint to openId", () => {
|
|
28
|
-
const channel = createFeishuChannel(MOCK_CONFIG, {}, MOCK_CLIENT);
|
|
29
|
-
const messaging = channel.base.messaging as Record<string, unknown>;
|
|
30
|
-
const resolver = messaging.targetResolver as Record<string, unknown>;
|
|
31
|
-
expect(resolver.hint).toBe("openId");
|
|
32
|
-
});
|
|
33
|
-
|
|
34
|
-
it("should set deliveryMode to gateway", () => {
|
|
35
|
-
const channel = createFeishuChannel(MOCK_CONFIG, {}, MOCK_CLIENT);
|
|
36
|
-
const outbound = channel.base.outbound as Record<string, string> | undefined;
|
|
37
|
-
expect(outbound?.deliveryMode).toBe("gateway");
|
|
38
|
-
});
|
|
39
|
-
|
|
40
|
-
it("should delegate sendText to feishuClient", async () => {
|
|
41
|
-
const channel = createFeishuChannel(MOCK_CONFIG, {}, MOCK_CLIENT);
|
|
42
|
-
const outbound = channel.base.outbound as {
|
|
43
|
-
sendText: (params: { to: string; text: string }) => Promise<
|
|
44
|
-
{ channel: string; messageId: string } |
|
|
45
|
-
{ channel: string; messageId: string; meta: { error: string } }
|
|
46
|
-
>;
|
|
47
|
-
};
|
|
48
|
-
const result = await outbound.sendText({ to: "ou-user", text: "hello" });
|
|
49
|
-
expect(result.channel).toBe("feishu");
|
|
50
|
-
expect(result.messageId).toBe("msg-123");
|
|
51
|
-
expect(MOCK_CLIENT.sendMessage).toHaveBeenCalledWith("ou-user", "hello");
|
|
52
|
-
});
|
|
53
|
-
|
|
54
|
-
it("should return error meta when sendMessage fails", async () => {
|
|
55
|
-
const failClient = {
|
|
56
|
-
...MOCK_CLIENT,
|
|
57
|
-
sendMessage: vi.fn().mockResolvedValue({ success: false, error: "API error" }),
|
|
58
|
-
} as unknown as FeishuApiClient;
|
|
59
|
-
const channel = createFeishuChannel(MOCK_CONFIG, {}, failClient);
|
|
60
|
-
const outbound = channel.base.outbound as {
|
|
61
|
-
sendText: (params: { to: string; text: string }) => Promise<
|
|
62
|
-
{ channel: string; messageId: string } |
|
|
63
|
-
{ channel: string; messageId: string; meta: { error: string } }
|
|
64
|
-
>;
|
|
65
|
-
};
|
|
66
|
-
const result = await outbound.sendText({ to: "ou-user", text: "hello" });
|
|
67
|
-
expect("meta" in result && result.meta?.error).toBe("API error");
|
|
68
|
-
});
|
|
69
|
-
|
|
70
|
-
it("should reflect configured status from feishuClient", () => {
|
|
71
|
-
const channel = createFeishuChannel(MOCK_CONFIG, {}, MOCK_CLIENT);
|
|
72
|
-
const config = channel.base.config as { isConfigured: () => boolean };
|
|
73
|
-
expect(config.isConfigured()).toBe(true);
|
|
74
|
-
});
|
|
75
|
-
});
|
|
76
|
-
|
|
77
|
-
describe("handleFeishuWebhook", () => {
|
|
78
|
-
it("should respond to challenge verification", async () => {
|
|
79
|
-
const { handler } = handleFeishuWebhook(MOCK_CONFIG, MOCK_CLIENT, {});
|
|
80
|
-
const req = new Request("http://localhost:9222/webhook/feishu?challenge=abc123");
|
|
81
|
-
const resp = await handler(req);
|
|
82
|
-
const body = (await resp.json()) as { challenge: string };
|
|
83
|
-
expect(body.challenge).toBe("abc123");
|
|
84
|
-
});
|
|
85
|
-
|
|
86
|
-
it("should reject request with invalid signature", async () => {
|
|
87
|
-
const { handler } = handleFeishuWebhook(MOCK_CONFIG, MOCK_CLIENT, {});
|
|
88
|
-
const req = new Request("http://localhost:9222/webhook/feishu", {
|
|
89
|
-
method: "POST",
|
|
90
|
-
headers: { "x-lark-signature": "invalid-sig" },
|
|
91
|
-
body: JSON.stringify({ type: "url_verification" }),
|
|
92
|
-
});
|
|
93
|
-
const resp = await handler(req);
|
|
94
|
-
expect(resp.status).toBe(400);
|
|
95
|
-
});
|
|
96
|
-
|
|
97
|
-
it("should extract openId and message from im.message.receive_v1 event", async () => {
|
|
98
|
-
const { handler } = handleFeishuWebhook(MOCK_CONFIG, MOCK_CLIENT, {});
|
|
99
|
-
const eventBody = {
|
|
100
|
-
schema: "2.0",
|
|
101
|
-
header: {
|
|
102
|
-
eventId: "evt-1",
|
|
103
|
-
eventType: "im.message.receive_v1",
|
|
104
|
-
createTime: "1234567890000",
|
|
105
|
-
token: "v-token",
|
|
106
|
-
appId: "app-123",
|
|
107
|
-
tenantKey: "tk-123",
|
|
108
|
-
},
|
|
109
|
-
event: {
|
|
110
|
-
sender: { senderId: { openId: "ou-sender-1" } },
|
|
111
|
-
message: {
|
|
112
|
-
messageId: "msg-1",
|
|
113
|
-
msgType: "text",
|
|
114
|
-
createTime: "1234567890000",
|
|
115
|
-
chatId: "chat-1",
|
|
116
|
-
chatType: "p2p",
|
|
117
|
-
content: '{"text":"hello from feishu"}',
|
|
118
|
-
},
|
|
119
|
-
},
|
|
120
|
-
};
|
|
121
|
-
const body = JSON.stringify(eventBody);
|
|
122
|
-
const sig = 'Tk4MriFS/RRZ9zfM0EHVMKk45vyHMPqTNwc6stjTOxo=';
|
|
123
|
-
const req = new Request("http://localhost:9222/webhook/feishu", {
|
|
124
|
-
method: "POST",
|
|
125
|
-
headers: { "content-type": "application/json", "x-lark-signature": sig },
|
|
126
|
-
body,
|
|
127
|
-
});
|
|
128
|
-
const resp = await handler(req);
|
|
129
|
-
expect(resp.status).toBe(200);
|
|
130
|
-
});
|
|
131
|
-
|
|
132
|
-
it("should ignore non-im.message.receive_v1 events", async () => {
|
|
133
|
-
const { handler } = handleFeishuWebhook(MOCK_CONFIG, MOCK_CLIENT, {});
|
|
134
|
-
const eventBody = {
|
|
135
|
-
schema: "2.0",
|
|
136
|
-
header: {
|
|
137
|
-
eventId: "evt-2",
|
|
138
|
-
eventType: "im.chat.member.bot.added_v1",
|
|
139
|
-
createTime: "1234567890000",
|
|
140
|
-
token: "v-token",
|
|
141
|
-
appId: "app-123",
|
|
142
|
-
tenantKey: "tk-123",
|
|
143
|
-
},
|
|
144
|
-
event: {},
|
|
145
|
-
};
|
|
146
|
-
const body = JSON.stringify(eventBody);
|
|
147
|
-
const sig = 'fS/7P4Dm1/UjN5vIolDq4Vgc8welngorAiRmo6tk4H8=';
|
|
148
|
-
const req = new Request("http://localhost:9222/webhook/feishu", {
|
|
149
|
-
method: "POST",
|
|
150
|
-
headers: { "content-type": "application/json", "x-lark-signature": sig },
|
|
151
|
-
body,
|
|
152
|
-
});
|
|
153
|
-
const resp = await handler(req);
|
|
154
|
-
expect(resp.status).toBe(200);
|
|
155
|
-
});
|
|
156
|
-
|
|
157
|
-
it("should extract plain text from JSON content field", async () => {
|
|
158
|
-
const { handler } = handleFeishuWebhook(MOCK_CONFIG, MOCK_CLIENT, {});
|
|
159
|
-
const eventBody = {
|
|
160
|
-
schema: "2.0",
|
|
161
|
-
header: {
|
|
162
|
-
eventId: "evt-3",
|
|
163
|
-
eventType: "im.message.receive_v1",
|
|
164
|
-
createTime: "1234567890000",
|
|
165
|
-
token: "v-token",
|
|
166
|
-
appId: "app-123",
|
|
167
|
-
tenantKey: "tk-123",
|
|
168
|
-
},
|
|
169
|
-
event: {
|
|
170
|
-
sender: { senderId: { openId: "ou-sender" } },
|
|
171
|
-
message: {
|
|
172
|
-
messageId: "msg-3",
|
|
173
|
-
msgType: "text",
|
|
174
|
-
createTime: "1234567890000",
|
|
175
|
-
chatId: "chat-1",
|
|
176
|
-
chatType: "p2p",
|
|
177
|
-
content: '{"text":"邀请用户B吃饭"}',
|
|
178
|
-
},
|
|
179
|
-
},
|
|
180
|
-
};
|
|
181
|
-
const body = JSON.stringify(eventBody);
|
|
182
|
-
const sig = 'cV4wR/Bd4TkU7oaZJis9LNpBTFWEjRRkGdpkNeljEi8=';
|
|
183
|
-
const req = new Request("http://localhost:9222/webhook/feishu", {
|
|
184
|
-
method: "POST",
|
|
185
|
-
headers: { "content-type": "application/json", "x-lark-signature": sig },
|
|
186
|
-
body,
|
|
187
|
-
});
|
|
188
|
-
const resp = await handler(req);
|
|
189
|
-
expect(resp.status).toBe(200);
|
|
190
|
-
});
|
|
191
|
-
});
|
package/src/feishu-channel.ts
DELETED
|
@@ -1,253 +0,0 @@
|
|
|
1
|
-
import { createChatChannelPlugin } from "openclaw/plugin-sdk/core";
|
|
2
|
-
import type { ChannelPlugin } from "openclaw/plugin-sdk/core";
|
|
3
|
-
import { createHmac } from "node:crypto";
|
|
4
|
-
import { FeishuApiClient } from "./feishu-client.js";
|
|
5
|
-
import type { FeishuChannelConfig } from "./feishu-types.js";
|
|
6
|
-
|
|
7
|
-
export interface FeishuChannelDeps {
|
|
8
|
-
logger?: {
|
|
9
|
-
info?: (msg: string) => void;
|
|
10
|
-
warn?: (msg: string) => void;
|
|
11
|
-
error?: (msg: string) => void;
|
|
12
|
-
};
|
|
13
|
-
}
|
|
14
|
-
|
|
15
|
-
/** Plugin type with a base wrapper for test compatibility */
|
|
16
|
-
export interface FeishuChannelPlugin extends ChannelPlugin {
|
|
17
|
-
base: Record<string, unknown>;
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
export function createFeishuChannel(
|
|
21
|
-
config: FeishuChannelConfig,
|
|
22
|
-
deps?: FeishuChannelDeps,
|
|
23
|
-
feishuClient?: FeishuApiClient,
|
|
24
|
-
): FeishuChannelPlugin {
|
|
25
|
-
const client = feishuClient ?? new FeishuApiClient(config, deps);
|
|
26
|
-
|
|
27
|
-
const raw = createChatChannelPlugin<{ accountId?: string }>({
|
|
28
|
-
base: {
|
|
29
|
-
id: "feishu",
|
|
30
|
-
meta: {
|
|
31
|
-
id: "feishu",
|
|
32
|
-
label: "Feishu",
|
|
33
|
-
selectionLabel: "Feishu",
|
|
34
|
-
docsPath: "/channels/feishu",
|
|
35
|
-
docsLabel: "feishu",
|
|
36
|
-
blurb: "Feishu integration for user interaction.",
|
|
37
|
-
systemImage: "message" as string | undefined,
|
|
38
|
-
},
|
|
39
|
-
capabilities: {
|
|
40
|
-
chatTypes: ["direct"],
|
|
41
|
-
media: false,
|
|
42
|
-
blockStreaming: true,
|
|
43
|
-
},
|
|
44
|
-
configSchema: {
|
|
45
|
-
schema: {
|
|
46
|
-
type: "object",
|
|
47
|
-
additionalProperties: false,
|
|
48
|
-
properties: {
|
|
49
|
-
appId: { type: "string" },
|
|
50
|
-
appSecret: { type: "string" },
|
|
51
|
-
webhookPort: { type: "number" },
|
|
52
|
-
webhookPath: { type: "string" },
|
|
53
|
-
},
|
|
54
|
-
},
|
|
55
|
-
},
|
|
56
|
-
config: {
|
|
57
|
-
listAccountIds: () => ["default"],
|
|
58
|
-
resolveAccount: () => ({
|
|
59
|
-
accountId: "default",
|
|
60
|
-
configured: client.isConfigured(),
|
|
61
|
-
enabled: true,
|
|
62
|
-
}),
|
|
63
|
-
isConfigured: () => client.isConfigured(),
|
|
64
|
-
isEnabled: () => true,
|
|
65
|
-
describeAccount: () => ({
|
|
66
|
-
accountId: "default",
|
|
67
|
-
name: "default",
|
|
68
|
-
configured: client.isConfigured(),
|
|
69
|
-
enabled: true,
|
|
70
|
-
}),
|
|
71
|
-
},
|
|
72
|
-
messaging: {
|
|
73
|
-
normalizeTarget: (raw: string) => raw.trim(),
|
|
74
|
-
targetResolver: {
|
|
75
|
-
looksLikeId: () => true,
|
|
76
|
-
hint: "openId",
|
|
77
|
-
},
|
|
78
|
-
},
|
|
79
|
-
},
|
|
80
|
-
outbound: {
|
|
81
|
-
deliveryMode: "gateway",
|
|
82
|
-
sendText: async ({ to, text }) => {
|
|
83
|
-
try {
|
|
84
|
-
const result = await client.sendMessage(to, text);
|
|
85
|
-
if (result.success && result.messageId) {
|
|
86
|
-
return { channel: "feishu", messageId: result.messageId };
|
|
87
|
-
}
|
|
88
|
-
return {
|
|
89
|
-
channel: "feishu",
|
|
90
|
-
messageId: `feishu-${Date.now()}`,
|
|
91
|
-
meta: { error: result.error ?? "send failed" },
|
|
92
|
-
};
|
|
93
|
-
} catch (err) {
|
|
94
|
-
return {
|
|
95
|
-
channel: "feishu",
|
|
96
|
-
messageId: `feishu-${Date.now()}`,
|
|
97
|
-
meta: { error: String(err) },
|
|
98
|
-
};
|
|
99
|
-
}
|
|
100
|
-
},
|
|
101
|
-
},
|
|
102
|
-
});
|
|
103
|
-
|
|
104
|
-
// Attach base wrapper so tests can access channel.base.* (mirrors the nested input structure)
|
|
105
|
-
const plugin = raw as FeishuChannelPlugin;
|
|
106
|
-
plugin.base = {
|
|
107
|
-
id: plugin.id,
|
|
108
|
-
meta: {
|
|
109
|
-
...plugin.meta,
|
|
110
|
-
systemImage: (plugin.meta as Record<string, unknown>).systemImage ?? "message",
|
|
111
|
-
},
|
|
112
|
-
capabilities: plugin.capabilities,
|
|
113
|
-
outbound: plugin.outbound,
|
|
114
|
-
config: plugin.config,
|
|
115
|
-
messaging: plugin.messaging,
|
|
116
|
-
};
|
|
117
|
-
|
|
118
|
-
return plugin;
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
export function handleFeishuWebhook(
|
|
122
|
-
config: FeishuChannelConfig,
|
|
123
|
-
feishuClient: FeishuApiClient,
|
|
124
|
-
deps: FeishuChannelDeps,
|
|
125
|
-
): {
|
|
126
|
-
startServer: () => Promise<() => Promise<void>>;
|
|
127
|
-
handler: (req: Request) => Promise<Response>;
|
|
128
|
-
} {
|
|
129
|
-
const { webhookPort, webhookPath, appSecret } = config;
|
|
130
|
-
|
|
131
|
-
function verifySignature(body: string, signature: string | null): boolean {
|
|
132
|
-
if (!signature) return false;
|
|
133
|
-
const expected = createHmac("sha256", appSecret)
|
|
134
|
-
.update(body)
|
|
135
|
-
.digest("base64");
|
|
136
|
-
return signature === expected;
|
|
137
|
-
}
|
|
138
|
-
|
|
139
|
-
async function handler(req: Request): Promise<Response> {
|
|
140
|
-
const url = new URL(req.url);
|
|
141
|
-
|
|
142
|
-
// Challenge verification (GET)
|
|
143
|
-
if (req.method === "GET") {
|
|
144
|
-
const challenge = url.searchParams.get("challenge");
|
|
145
|
-
if (challenge) {
|
|
146
|
-
return new Response(JSON.stringify({ challenge }), {
|
|
147
|
-
status: 200,
|
|
148
|
-
headers: { "content-type": "application/json" },
|
|
149
|
-
});
|
|
150
|
-
}
|
|
151
|
-
return new Response("Not Found", { status: 404 });
|
|
152
|
-
}
|
|
153
|
-
|
|
154
|
-
// Event handling (POST)
|
|
155
|
-
if (req.method === "POST") {
|
|
156
|
-
const body = await req.text();
|
|
157
|
-
const signature = req.headers.get("x-lark-signature");
|
|
158
|
-
|
|
159
|
-
if (!verifySignature(body, signature)) {
|
|
160
|
-
return new Response("Invalid signature", { status: 400 });
|
|
161
|
-
}
|
|
162
|
-
|
|
163
|
-
try {
|
|
164
|
-
const event = JSON.parse(body);
|
|
165
|
-
|
|
166
|
-
// Only process im.message.receive_v1 events
|
|
167
|
-
const eventType = event.header?.eventType;
|
|
168
|
-
if (eventType !== "im.message.receive_v1") {
|
|
169
|
-
return new Response(null, { status: 200 });
|
|
170
|
-
}
|
|
171
|
-
|
|
172
|
-
const message = event.event?.message;
|
|
173
|
-
const openId = event.event?.sender?.senderId?.openId;
|
|
174
|
-
|
|
175
|
-
if (message && openId && message.msgType === "text") {
|
|
176
|
-
try {
|
|
177
|
-
const content = JSON.parse(message.content) as { text?: string };
|
|
178
|
-
const text = content.text?.trim();
|
|
179
|
-
if (text) {
|
|
180
|
-
await feishuClient.sendMessage(openId, text);
|
|
181
|
-
}
|
|
182
|
-
} catch {
|
|
183
|
-
deps.logger?.warn?.(
|
|
184
|
-
`Failed to parse message content: ${message.content}`,
|
|
185
|
-
);
|
|
186
|
-
}
|
|
187
|
-
}
|
|
188
|
-
|
|
189
|
-
return new Response(null, { status: 200 });
|
|
190
|
-
} catch {
|
|
191
|
-
return new Response("Invalid JSON", { status: 400 });
|
|
192
|
-
}
|
|
193
|
-
}
|
|
194
|
-
|
|
195
|
-
return new Response("Method Not Allowed", { status: 405 });
|
|
196
|
-
}
|
|
197
|
-
|
|
198
|
-
async function startServer(): Promise<() => Promise<void>> {
|
|
199
|
-
const httpModule = await import("node:http");
|
|
200
|
-
const server = httpModule.createServer(
|
|
201
|
-
async (req, res) => {
|
|
202
|
-
try {
|
|
203
|
-
const host = req.headers.host ?? "localhost";
|
|
204
|
-
const reqUrl = req.url ?? "/";
|
|
205
|
-
const url = new URL(reqUrl, `http://${host}`);
|
|
206
|
-
// Convert Node.js IncomingMessage to a Web API-like Request for handler
|
|
207
|
-
const request = new Request(url.toString(), {
|
|
208
|
-
method: req.method,
|
|
209
|
-
headers: Object.fromEntries(
|
|
210
|
-
Object.entries(req.headers as Record<string, string>),
|
|
211
|
-
),
|
|
212
|
-
body: req.method !== "GET" && req.method !== "HEAD"
|
|
213
|
-
? await new Promise<string>((resolve) => {
|
|
214
|
-
const chunks: Buffer[] = [];
|
|
215
|
-
req.on("data", (chunk: Buffer) => chunks.push(chunk));
|
|
216
|
-
req.on("end", () => resolve(Buffer.concat(chunks).toString()));
|
|
217
|
-
})
|
|
218
|
-
: undefined,
|
|
219
|
-
});
|
|
220
|
-
|
|
221
|
-
const response = await handler(request);
|
|
222
|
-
res.statusCode = response.status;
|
|
223
|
-
// Forward response headers to Node.js response
|
|
224
|
-
const headersObj = Object.fromEntries(
|
|
225
|
-
(response.headers as unknown as Iterable<[string, string]>),
|
|
226
|
-
) as Record<string, string>;
|
|
227
|
-
for (const [key, value] of Object.entries(headersObj)) {
|
|
228
|
-
res.setHeader(key, value);
|
|
229
|
-
}
|
|
230
|
-
const responseBody = await response.text();
|
|
231
|
-
res.end(responseBody);
|
|
232
|
-
} catch (err) {
|
|
233
|
-
deps.logger?.error?.(`Webhook handler error: ${err}`);
|
|
234
|
-
res.statusCode = 500;
|
|
235
|
-
res.end("Internal Server Error");
|
|
236
|
-
}
|
|
237
|
-
},
|
|
238
|
-
);
|
|
239
|
-
|
|
240
|
-
await new Promise<void>((resolve) => {
|
|
241
|
-
server.listen(webhookPort, () => {
|
|
242
|
-
resolve();
|
|
243
|
-
});
|
|
244
|
-
});
|
|
245
|
-
|
|
246
|
-
return () =>
|
|
247
|
-
new Promise<void>((resolve) => {
|
|
248
|
-
server.close(() => resolve());
|
|
249
|
-
});
|
|
250
|
-
}
|
|
251
|
-
|
|
252
|
-
return { startServer, handler };
|
|
253
|
-
}
|