@yanhaidao/wecom 1.0.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/README.md +93 -0
- package/assets/link-me.jpg +0 -0
- package/clawdbot.plugin.json +9 -0
- package/index.ts +23 -0
- package/package.json +41 -0
- package/src/accounts.ts +73 -0
- package/src/channel.ts +212 -0
- package/src/config-schema.ts +36 -0
- package/src/crypto.test.ts +32 -0
- package/src/crypto.ts +133 -0
- package/src/monitor.ts +646 -0
- package/src/monitor.webhook.test.ts +161 -0
- package/src/runtime.ts +15 -0
- package/src/types.ts +77 -0
- package/tsconfig.json +22 -0
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
import { createServer } from "node:http";
|
|
2
|
+
import type { AddressInfo } from "node:net";
|
|
3
|
+
|
|
4
|
+
import { describe, expect, it } from "vitest";
|
|
5
|
+
|
|
6
|
+
import type { ClawdbotConfig } from "clawdbot/plugin-sdk";
|
|
7
|
+
|
|
8
|
+
import type { ResolvedWecomAccount } from "./types.js";
|
|
9
|
+
import { computeWecomMsgSignature, decryptWecomEncrypted, encryptWecomPlaintext } from "./crypto.js";
|
|
10
|
+
import { handleWecomWebhookRequest, registerWecomWebhookTarget } from "./monitor.js";
|
|
11
|
+
|
|
12
|
+
async function withServer(
|
|
13
|
+
handler: Parameters<typeof createServer>[0],
|
|
14
|
+
fn: (baseUrl: string) => Promise<void>,
|
|
15
|
+
) {
|
|
16
|
+
const server = createServer(handler);
|
|
17
|
+
await new Promise<void>((resolve) => {
|
|
18
|
+
server.listen(0, "127.0.0.1", () => resolve());
|
|
19
|
+
});
|
|
20
|
+
const address = server.address() as AddressInfo | null;
|
|
21
|
+
if (!address) throw new Error("missing server address");
|
|
22
|
+
try {
|
|
23
|
+
await fn(`http://127.0.0.1:${address.port}`);
|
|
24
|
+
} finally {
|
|
25
|
+
await new Promise<void>((resolve) => server.close(() => resolve()));
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
describe("handleWecomWebhookRequest", () => {
|
|
30
|
+
const token = "test-token";
|
|
31
|
+
const encodingAESKey = "abcdefghijklmnopqrstuvwxyz0123456789ABCDEFG";
|
|
32
|
+
|
|
33
|
+
it("handles GET url verification", async () => {
|
|
34
|
+
const account: ResolvedWecomAccount = {
|
|
35
|
+
accountId: "default",
|
|
36
|
+
name: "Test",
|
|
37
|
+
enabled: true,
|
|
38
|
+
configured: true,
|
|
39
|
+
token,
|
|
40
|
+
encodingAESKey,
|
|
41
|
+
receiveId: "",
|
|
42
|
+
config: { webhookPath: "/hook", token, encodingAESKey },
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
const unregister = registerWecomWebhookTarget({
|
|
46
|
+
account,
|
|
47
|
+
config: {} as ClawdbotConfig,
|
|
48
|
+
runtime: {},
|
|
49
|
+
core: {} as any,
|
|
50
|
+
path: "/hook",
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
try {
|
|
54
|
+
await withServer(async (req, res) => {
|
|
55
|
+
const handled = await handleWecomWebhookRequest(req, res);
|
|
56
|
+
if (!handled) {
|
|
57
|
+
res.statusCode = 404;
|
|
58
|
+
res.end("not found");
|
|
59
|
+
}
|
|
60
|
+
}, async (baseUrl) => {
|
|
61
|
+
const timestamp = "13500001234";
|
|
62
|
+
const nonce = "123412323";
|
|
63
|
+
const echostr = encryptWecomPlaintext({
|
|
64
|
+
encodingAESKey,
|
|
65
|
+
receiveId: "",
|
|
66
|
+
plaintext: "ping",
|
|
67
|
+
});
|
|
68
|
+
const msg_signature = computeWecomMsgSignature({ token, timestamp, nonce, encrypt: echostr });
|
|
69
|
+
const response = await fetch(
|
|
70
|
+
`${baseUrl}/hook?msg_signature=${encodeURIComponent(msg_signature)}×tamp=${encodeURIComponent(timestamp)}&nonce=${encodeURIComponent(nonce)}&echostr=${encodeURIComponent(echostr)}`,
|
|
71
|
+
);
|
|
72
|
+
expect(response.status).toBe(200);
|
|
73
|
+
expect(await response.text()).toBe("ping");
|
|
74
|
+
});
|
|
75
|
+
} finally {
|
|
76
|
+
unregister();
|
|
77
|
+
}
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it("handles POST callback and returns encrypted stream placeholder", async () => {
|
|
81
|
+
const account: ResolvedWecomAccount = {
|
|
82
|
+
accountId: "default",
|
|
83
|
+
name: "Test",
|
|
84
|
+
enabled: true,
|
|
85
|
+
configured: true,
|
|
86
|
+
token,
|
|
87
|
+
encodingAESKey,
|
|
88
|
+
receiveId: "",
|
|
89
|
+
config: { webhookPath: "/hook", token, encodingAESKey },
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
const unregister = registerWecomWebhookTarget({
|
|
93
|
+
account,
|
|
94
|
+
config: {} as ClawdbotConfig,
|
|
95
|
+
runtime: {},
|
|
96
|
+
core: {} as any,
|
|
97
|
+
path: "/hook",
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
try {
|
|
101
|
+
await withServer(async (req, res) => {
|
|
102
|
+
const handled = await handleWecomWebhookRequest(req, res);
|
|
103
|
+
if (!handled) {
|
|
104
|
+
res.statusCode = 404;
|
|
105
|
+
res.end("not found");
|
|
106
|
+
}
|
|
107
|
+
}, async (baseUrl) => {
|
|
108
|
+
const timestamp = "1700000000";
|
|
109
|
+
const nonce = "nonce";
|
|
110
|
+
const plain = JSON.stringify({
|
|
111
|
+
msgid: "MSGID",
|
|
112
|
+
aibotid: "AIBOTID",
|
|
113
|
+
chattype: "single",
|
|
114
|
+
from: { userid: "USERID" },
|
|
115
|
+
response_url: "RESPONSEURL",
|
|
116
|
+
msgtype: "text",
|
|
117
|
+
text: { content: "hello" },
|
|
118
|
+
});
|
|
119
|
+
const encrypt = encryptWecomPlaintext({ encodingAESKey, receiveId: "", plaintext: plain });
|
|
120
|
+
const msg_signature = computeWecomMsgSignature({ token, timestamp, nonce, encrypt });
|
|
121
|
+
|
|
122
|
+
const response = await fetch(
|
|
123
|
+
`${baseUrl}/hook?msg_signature=${encodeURIComponent(msg_signature)}×tamp=${encodeURIComponent(timestamp)}&nonce=${encodeURIComponent(nonce)}`,
|
|
124
|
+
{
|
|
125
|
+
method: "POST",
|
|
126
|
+
headers: { "content-type": "application/json" },
|
|
127
|
+
body: JSON.stringify({ encrypt }),
|
|
128
|
+
},
|
|
129
|
+
);
|
|
130
|
+
expect(response.status).toBe(200);
|
|
131
|
+
const json = JSON.parse(await response.text()) as any;
|
|
132
|
+
expect(typeof json.encrypt).toBe("string");
|
|
133
|
+
expect(typeof json.msgsignature).toBe("string");
|
|
134
|
+
expect(typeof json.timestamp).toBe("string");
|
|
135
|
+
expect(typeof json.nonce).toBe("string");
|
|
136
|
+
|
|
137
|
+
const replyPlain = decryptWecomEncrypted({
|
|
138
|
+
encodingAESKey,
|
|
139
|
+
receiveId: "",
|
|
140
|
+
encrypt: json.encrypt,
|
|
141
|
+
});
|
|
142
|
+
const reply = JSON.parse(replyPlain) as any;
|
|
143
|
+
expect(reply.msgtype).toBe("stream");
|
|
144
|
+
expect(reply.stream?.content).toBe("1");
|
|
145
|
+
expect(reply.stream?.finish).toBe(false);
|
|
146
|
+
expect(typeof reply.stream?.id).toBe("string");
|
|
147
|
+
expect(reply.stream?.id.length).toBeGreaterThan(0);
|
|
148
|
+
|
|
149
|
+
const expectedSig = computeWecomMsgSignature({
|
|
150
|
+
token,
|
|
151
|
+
timestamp: String(json.timestamp),
|
|
152
|
+
nonce: String(json.nonce),
|
|
153
|
+
encrypt: String(json.encrypt),
|
|
154
|
+
});
|
|
155
|
+
expect(json.msgsignature).toBe(expectedSig);
|
|
156
|
+
});
|
|
157
|
+
} finally {
|
|
158
|
+
unregister();
|
|
159
|
+
}
|
|
160
|
+
});
|
|
161
|
+
});
|
package/src/runtime.ts
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { PluginRuntime } from "clawdbot/plugin-sdk";
|
|
2
|
+
|
|
3
|
+
let runtime: PluginRuntime | null = null;
|
|
4
|
+
|
|
5
|
+
export function setWecomRuntime(next: PluginRuntime): void {
|
|
6
|
+
runtime = next;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function getWecomRuntime(): PluginRuntime {
|
|
10
|
+
if (!runtime) {
|
|
11
|
+
throw new Error("WeCom runtime not initialized");
|
|
12
|
+
}
|
|
13
|
+
return runtime;
|
|
14
|
+
}
|
|
15
|
+
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
export type WecomDmConfig = {
|
|
2
|
+
policy?: "pairing" | "allowlist" | "open" | "disabled";
|
|
3
|
+
allowFrom?: Array<string | number>;
|
|
4
|
+
};
|
|
5
|
+
|
|
6
|
+
export type WecomAccountConfig = {
|
|
7
|
+
name?: string;
|
|
8
|
+
enabled?: boolean;
|
|
9
|
+
|
|
10
|
+
webhookPath?: string;
|
|
11
|
+
token?: string;
|
|
12
|
+
encodingAESKey?: string;
|
|
13
|
+
receiveId?: string;
|
|
14
|
+
|
|
15
|
+
dm?: WecomDmConfig;
|
|
16
|
+
welcomeText?: string;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
export type WecomConfig = WecomAccountConfig & {
|
|
20
|
+
accounts?: Record<string, WecomAccountConfig>;
|
|
21
|
+
defaultAccount?: string;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
export type ResolvedWecomAccount = {
|
|
25
|
+
accountId: string;
|
|
26
|
+
name?: string;
|
|
27
|
+
enabled: boolean;
|
|
28
|
+
configured: boolean;
|
|
29
|
+
token?: string;
|
|
30
|
+
encodingAESKey?: string;
|
|
31
|
+
receiveId: string;
|
|
32
|
+
config: WecomAccountConfig;
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
export type WecomInboundBase = {
|
|
36
|
+
msgid?: string;
|
|
37
|
+
aibotid?: string;
|
|
38
|
+
chattype?: "single" | "group";
|
|
39
|
+
chatid?: string;
|
|
40
|
+
response_url?: string;
|
|
41
|
+
from?: { userid?: string; corpid?: string };
|
|
42
|
+
msgtype?: string;
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
export type WecomInboundText = WecomInboundBase & {
|
|
46
|
+
msgtype: "text";
|
|
47
|
+
text?: { content?: string };
|
|
48
|
+
quote?: unknown;
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
export type WecomInboundVoice = WecomInboundBase & {
|
|
52
|
+
msgtype: "voice";
|
|
53
|
+
voice?: { content?: string };
|
|
54
|
+
quote?: unknown;
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
export type WecomInboundStreamRefresh = WecomInboundBase & {
|
|
58
|
+
msgtype: "stream";
|
|
59
|
+
stream?: { id?: string };
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
export type WecomInboundEvent = WecomInboundBase & {
|
|
63
|
+
msgtype: "event";
|
|
64
|
+
create_time?: number;
|
|
65
|
+
event?: {
|
|
66
|
+
eventtype?: string;
|
|
67
|
+
[key: string]: unknown;
|
|
68
|
+
};
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
export type WecomInboundMessage =
|
|
72
|
+
| WecomInboundText
|
|
73
|
+
| WecomInboundVoice
|
|
74
|
+
| WecomInboundStreamRefresh
|
|
75
|
+
| WecomInboundEvent
|
|
76
|
+
| (WecomInboundBase & Record<string, unknown>);
|
|
77
|
+
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2022",
|
|
4
|
+
"module": "NodeNext",
|
|
5
|
+
"moduleResolution": "NodeNext",
|
|
6
|
+
"outDir": "dist",
|
|
7
|
+
"rootDir": ".",
|
|
8
|
+
"declaration": false,
|
|
9
|
+
"sourceMap": false,
|
|
10
|
+
"strict": true,
|
|
11
|
+
"skipLibCheck": true
|
|
12
|
+
},
|
|
13
|
+
"include": [
|
|
14
|
+
"index.ts",
|
|
15
|
+
"src/**/*.ts"
|
|
16
|
+
],
|
|
17
|
+
"exclude": [
|
|
18
|
+
"dist",
|
|
19
|
+
"node_modules",
|
|
20
|
+
"**/*.test.ts"
|
|
21
|
+
]
|
|
22
|
+
}
|