@nextclaw/channel-runtime 0.1.6 → 0.1.7
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 +1 -0
- package/dist/index.d.ts +25 -1
- package/dist/index.js +273 -0
- package/package.json +2 -2
package/README.md
CHANGED
package/dist/index.d.ts
CHANGED
|
@@ -204,6 +204,25 @@ declare class TelegramChannel extends BaseChannel<Config["channels"]["telegram"]
|
|
|
204
204
|
private stopTyping;
|
|
205
205
|
}
|
|
206
206
|
|
|
207
|
+
declare class WeComChannel extends BaseChannel<Config["channels"]["wecom"]> {
|
|
208
|
+
name: string;
|
|
209
|
+
private server;
|
|
210
|
+
private accessToken;
|
|
211
|
+
private tokenExpiry;
|
|
212
|
+
private processedIds;
|
|
213
|
+
private processedSet;
|
|
214
|
+
constructor(config: Config["channels"]["wecom"], bus: MessageBus);
|
|
215
|
+
start(): Promise<void>;
|
|
216
|
+
stop(): Promise<void>;
|
|
217
|
+
send(msg: OutboundMessage): Promise<void>;
|
|
218
|
+
private handleCallbackRequest;
|
|
219
|
+
private handleVerificationRequest;
|
|
220
|
+
private handleInboundMessageRequest;
|
|
221
|
+
private verifySignature;
|
|
222
|
+
private getAccessToken;
|
|
223
|
+
private isDuplicate;
|
|
224
|
+
}
|
|
225
|
+
|
|
207
226
|
declare class WhatsAppChannel extends BaseChannel<Config["channels"]["whatsapp"]> {
|
|
208
227
|
name: string;
|
|
209
228
|
private ws;
|
|
@@ -256,6 +275,11 @@ declare const BUILTIN_CHANNEL_RUNTIMES: {
|
|
|
256
275
|
readonly isEnabled: (config: Config) => boolean;
|
|
257
276
|
readonly createChannel: (context: BuiltinChannelCreateContext) => DingTalkChannel;
|
|
258
277
|
};
|
|
278
|
+
readonly wecom: {
|
|
279
|
+
readonly id: "wecom";
|
|
280
|
+
readonly isEnabled: (config: Config) => boolean;
|
|
281
|
+
readonly createChannel: (context: BuiltinChannelCreateContext) => WeComChannel;
|
|
282
|
+
};
|
|
259
283
|
readonly email: {
|
|
260
284
|
readonly id: "email";
|
|
261
285
|
readonly isEnabled: (config: Config) => boolean;
|
|
@@ -277,4 +301,4 @@ declare const BUILTIN_CHANNEL_PLUGIN_IDS: BuiltinChannelId[];
|
|
|
277
301
|
declare function listBuiltinChannelRuntimes(): BuiltinChannelRuntime[];
|
|
278
302
|
declare function resolveBuiltinChannelRuntime(channelId: string): BuiltinChannelRuntime;
|
|
279
303
|
|
|
280
|
-
export { BUILTIN_CHANNEL_PLUGIN_IDS, type BuiltinChannelId, type BuiltinChannelRuntime, DingTalkChannel, DiscordChannel, EmailChannel, FeishuChannel, MochatChannel, QQChannel, SlackChannel, TelegramChannel, WhatsAppChannel, listBuiltinChannelRuntimes, resolveBuiltinChannelRuntime };
|
|
304
|
+
export { BUILTIN_CHANNEL_PLUGIN_IDS, type BuiltinChannelId, type BuiltinChannelRuntime, DingTalkChannel, DiscordChannel, EmailChannel, FeishuChannel, MochatChannel, QQChannel, SlackChannel, TelegramChannel, WeComChannel, WhatsAppChannel, listBuiltinChannelRuntimes, resolveBuiltinChannelRuntime };
|
package/dist/index.js
CHANGED
|
@@ -2668,6 +2668,273 @@ function markdownToTelegramHtml(text) {
|
|
|
2668
2668
|
return text;
|
|
2669
2669
|
}
|
|
2670
2670
|
|
|
2671
|
+
// src/channels/wecom.ts
|
|
2672
|
+
import { createHash } from "crypto";
|
|
2673
|
+
import { createServer } from "http";
|
|
2674
|
+
import { fetch as fetch5 } from "undici";
|
|
2675
|
+
var MSG_TYPE_FALLBACK = {
|
|
2676
|
+
image: "[image]",
|
|
2677
|
+
voice: "[voice]",
|
|
2678
|
+
video: "[video]",
|
|
2679
|
+
file: "[file]",
|
|
2680
|
+
location: "[location]",
|
|
2681
|
+
event: "[event]"
|
|
2682
|
+
};
|
|
2683
|
+
var TOKEN_EXPIRY_BUFFER_MS = 6e4;
|
|
2684
|
+
var WeComChannel = class extends BaseChannel {
|
|
2685
|
+
name = "wecom";
|
|
2686
|
+
server = null;
|
|
2687
|
+
accessToken = null;
|
|
2688
|
+
tokenExpiry = 0;
|
|
2689
|
+
processedIds = [];
|
|
2690
|
+
processedSet = /* @__PURE__ */ new Set();
|
|
2691
|
+
constructor(config, bus) {
|
|
2692
|
+
super(config, bus);
|
|
2693
|
+
}
|
|
2694
|
+
async start() {
|
|
2695
|
+
if (!this.config.corpId || !this.config.agentId || !this.config.secret || !this.config.token) {
|
|
2696
|
+
throw new Error("WeCom corpId/agentId/secret/token not configured");
|
|
2697
|
+
}
|
|
2698
|
+
this.running = true;
|
|
2699
|
+
await new Promise((resolve, reject) => {
|
|
2700
|
+
this.server = createServer((req, res) => {
|
|
2701
|
+
void this.handleCallbackRequest(req, res);
|
|
2702
|
+
});
|
|
2703
|
+
this.server.once("error", reject);
|
|
2704
|
+
this.server.listen(this.config.callbackPort, () => {
|
|
2705
|
+
this.server?.off("error", reject);
|
|
2706
|
+
resolve();
|
|
2707
|
+
});
|
|
2708
|
+
});
|
|
2709
|
+
}
|
|
2710
|
+
async stop() {
|
|
2711
|
+
this.running = false;
|
|
2712
|
+
if (!this.server) {
|
|
2713
|
+
return;
|
|
2714
|
+
}
|
|
2715
|
+
await new Promise((resolve) => {
|
|
2716
|
+
this.server?.close(() => resolve());
|
|
2717
|
+
});
|
|
2718
|
+
this.server = null;
|
|
2719
|
+
}
|
|
2720
|
+
async send(msg) {
|
|
2721
|
+
const receiver = msg.chatId?.trim();
|
|
2722
|
+
if (!receiver) {
|
|
2723
|
+
return;
|
|
2724
|
+
}
|
|
2725
|
+
const content = normalizeOutboundContent(msg);
|
|
2726
|
+
if (!content) {
|
|
2727
|
+
return;
|
|
2728
|
+
}
|
|
2729
|
+
const accessToken = await this.getAccessToken();
|
|
2730
|
+
const sendUrl = `https://qyapi.weixin.qq.com/cgi-bin/message/send?access_token=${encodeURIComponent(accessToken)}`;
|
|
2731
|
+
const agentIdNumber = Number(this.config.agentId);
|
|
2732
|
+
const payload = {
|
|
2733
|
+
touser: receiver,
|
|
2734
|
+
msgtype: "text",
|
|
2735
|
+
agentid: Number.isFinite(agentIdNumber) ? agentIdNumber : this.config.agentId,
|
|
2736
|
+
text: {
|
|
2737
|
+
content
|
|
2738
|
+
},
|
|
2739
|
+
safe: 0
|
|
2740
|
+
};
|
|
2741
|
+
const response = await fetch5(sendUrl, {
|
|
2742
|
+
method: "POST",
|
|
2743
|
+
headers: {
|
|
2744
|
+
"content-type": "application/json"
|
|
2745
|
+
},
|
|
2746
|
+
body: JSON.stringify(payload)
|
|
2747
|
+
});
|
|
2748
|
+
if (!response.ok) {
|
|
2749
|
+
throw new Error(`WeCom send failed: HTTP ${response.status}`);
|
|
2750
|
+
}
|
|
2751
|
+
const body = await response.json();
|
|
2752
|
+
const errcode = Number(body.errcode ?? -1);
|
|
2753
|
+
if (!Number.isFinite(errcode) || errcode !== 0) {
|
|
2754
|
+
const errmsg = typeof body.errmsg === "string" ? body.errmsg : "unknown error";
|
|
2755
|
+
throw new Error(`WeCom send failed: ${errcode} ${errmsg}`);
|
|
2756
|
+
}
|
|
2757
|
+
}
|
|
2758
|
+
async handleCallbackRequest(req, res) {
|
|
2759
|
+
const callbackPath = normalizeCallbackPath(this.config.callbackPath);
|
|
2760
|
+
const requestUrl = new URL(req.url ?? "/", `http://${req.headers.host ?? "127.0.0.1"}`);
|
|
2761
|
+
if (requestUrl.pathname !== callbackPath) {
|
|
2762
|
+
res.statusCode = 404;
|
|
2763
|
+
res.end("not found");
|
|
2764
|
+
return;
|
|
2765
|
+
}
|
|
2766
|
+
const method = (req.method ?? "GET").toUpperCase();
|
|
2767
|
+
if (method === "GET") {
|
|
2768
|
+
this.handleVerificationRequest(requestUrl, res);
|
|
2769
|
+
return;
|
|
2770
|
+
}
|
|
2771
|
+
if (method === "POST") {
|
|
2772
|
+
await this.handleInboundMessageRequest(req, requestUrl, res);
|
|
2773
|
+
return;
|
|
2774
|
+
}
|
|
2775
|
+
res.statusCode = 405;
|
|
2776
|
+
res.end("method not allowed");
|
|
2777
|
+
}
|
|
2778
|
+
handleVerificationRequest(requestUrl, res) {
|
|
2779
|
+
const timestamp = requestUrl.searchParams.get("timestamp") ?? "";
|
|
2780
|
+
const nonce = requestUrl.searchParams.get("nonce") ?? "";
|
|
2781
|
+
const echostr = requestUrl.searchParams.get("echostr") ?? "";
|
|
2782
|
+
const signature = requestUrl.searchParams.get("msg_signature") ?? requestUrl.searchParams.get("signature") ?? "";
|
|
2783
|
+
if (!timestamp || !nonce || !echostr || !signature) {
|
|
2784
|
+
res.statusCode = 400;
|
|
2785
|
+
res.end("invalid verification payload");
|
|
2786
|
+
return;
|
|
2787
|
+
}
|
|
2788
|
+
if (!this.verifySignature(timestamp, nonce, signature)) {
|
|
2789
|
+
res.statusCode = 401;
|
|
2790
|
+
res.end("signature mismatch");
|
|
2791
|
+
return;
|
|
2792
|
+
}
|
|
2793
|
+
res.statusCode = 200;
|
|
2794
|
+
res.setHeader("content-type", "text/plain; charset=utf-8");
|
|
2795
|
+
res.end(echostr);
|
|
2796
|
+
}
|
|
2797
|
+
async handleInboundMessageRequest(req, requestUrl, res) {
|
|
2798
|
+
const timestamp = requestUrl.searchParams.get("timestamp") ?? "";
|
|
2799
|
+
const nonce = requestUrl.searchParams.get("nonce") ?? "";
|
|
2800
|
+
const signature = requestUrl.searchParams.get("msg_signature") ?? requestUrl.searchParams.get("signature") ?? "";
|
|
2801
|
+
if (!timestamp || !nonce || !signature || !this.verifySignature(timestamp, nonce, signature)) {
|
|
2802
|
+
res.statusCode = 401;
|
|
2803
|
+
res.end("signature mismatch");
|
|
2804
|
+
return;
|
|
2805
|
+
}
|
|
2806
|
+
const rawBody = await readBody(req);
|
|
2807
|
+
if (!rawBody.trim()) {
|
|
2808
|
+
respondSuccess(res);
|
|
2809
|
+
return;
|
|
2810
|
+
}
|
|
2811
|
+
if (extractXmlField(rawBody, "Encrypt")) {
|
|
2812
|
+
respondSuccess(res);
|
|
2813
|
+
return;
|
|
2814
|
+
}
|
|
2815
|
+
const senderId = extractXmlField(rawBody, "FromUserName");
|
|
2816
|
+
if (!senderId || !this.isAllowed(senderId)) {
|
|
2817
|
+
respondSuccess(res);
|
|
2818
|
+
return;
|
|
2819
|
+
}
|
|
2820
|
+
const msgType = extractXmlField(rawBody, "MsgType") || "text";
|
|
2821
|
+
const msgId = extractXmlField(rawBody, "MsgId") || buildSyntheticMessageId(rawBody, senderId, msgType);
|
|
2822
|
+
if (this.isDuplicate(msgId)) {
|
|
2823
|
+
respondSuccess(res);
|
|
2824
|
+
return;
|
|
2825
|
+
}
|
|
2826
|
+
const content = extractXmlField(rawBody, "Content")?.trim() || MSG_TYPE_FALLBACK[msgType.toLowerCase()] || "[unsupported message]";
|
|
2827
|
+
await this.handleMessage({
|
|
2828
|
+
senderId,
|
|
2829
|
+
chatId: senderId,
|
|
2830
|
+
content,
|
|
2831
|
+
attachments: [],
|
|
2832
|
+
metadata: {
|
|
2833
|
+
message_id: msgId,
|
|
2834
|
+
wecom: {
|
|
2835
|
+
msgType,
|
|
2836
|
+
toUserName: extractXmlField(rawBody, "ToUserName"),
|
|
2837
|
+
agentId: extractXmlField(rawBody, "AgentID"),
|
|
2838
|
+
createTime: extractXmlField(rawBody, "CreateTime")
|
|
2839
|
+
}
|
|
2840
|
+
}
|
|
2841
|
+
});
|
|
2842
|
+
respondSuccess(res);
|
|
2843
|
+
}
|
|
2844
|
+
verifySignature(timestamp, nonce, signature) {
|
|
2845
|
+
const expected = createHash("sha1").update([this.config.token, timestamp, nonce].sort().join("")).digest("hex");
|
|
2846
|
+
return expected === signature;
|
|
2847
|
+
}
|
|
2848
|
+
async getAccessToken() {
|
|
2849
|
+
if (this.accessToken && Date.now() < this.tokenExpiry) {
|
|
2850
|
+
return this.accessToken;
|
|
2851
|
+
}
|
|
2852
|
+
const tokenUrl = `https://qyapi.weixin.qq.com/cgi-bin/gettoken?corpid=${encodeURIComponent(this.config.corpId)}&corpsecret=${encodeURIComponent(this.config.secret)}`;
|
|
2853
|
+
const response = await fetch5(tokenUrl, { method: "GET" });
|
|
2854
|
+
if (!response.ok) {
|
|
2855
|
+
throw new Error(`WeCom gettoken failed: HTTP ${response.status}`);
|
|
2856
|
+
}
|
|
2857
|
+
const body = await response.json();
|
|
2858
|
+
const errcode = Number(body.errcode ?? -1);
|
|
2859
|
+
if (!Number.isFinite(errcode) || errcode !== 0) {
|
|
2860
|
+
const errmsg = typeof body.errmsg === "string" ? body.errmsg : "unknown error";
|
|
2861
|
+
throw new Error(`WeCom gettoken failed: ${errcode} ${errmsg}`);
|
|
2862
|
+
}
|
|
2863
|
+
const accessToken = typeof body.access_token === "string" ? body.access_token : "";
|
|
2864
|
+
const expiresIn = Number(body.expires_in ?? 7200);
|
|
2865
|
+
if (!accessToken) {
|
|
2866
|
+
throw new Error("WeCom gettoken failed: missing access_token");
|
|
2867
|
+
}
|
|
2868
|
+
this.accessToken = accessToken;
|
|
2869
|
+
this.tokenExpiry = Date.now() + Math.max(0, expiresIn * 1e3 - TOKEN_EXPIRY_BUFFER_MS);
|
|
2870
|
+
return accessToken;
|
|
2871
|
+
}
|
|
2872
|
+
isDuplicate(messageId) {
|
|
2873
|
+
if (this.processedSet.has(messageId)) {
|
|
2874
|
+
return true;
|
|
2875
|
+
}
|
|
2876
|
+
this.processedSet.add(messageId);
|
|
2877
|
+
this.processedIds.push(messageId);
|
|
2878
|
+
if (this.processedIds.length > 1e3) {
|
|
2879
|
+
const removed = this.processedIds.splice(0, 500);
|
|
2880
|
+
for (const id of removed) {
|
|
2881
|
+
this.processedSet.delete(id);
|
|
2882
|
+
}
|
|
2883
|
+
}
|
|
2884
|
+
return false;
|
|
2885
|
+
}
|
|
2886
|
+
};
|
|
2887
|
+
function normalizeCallbackPath(path) {
|
|
2888
|
+
if (!path) {
|
|
2889
|
+
return "/wecom/callback";
|
|
2890
|
+
}
|
|
2891
|
+
return path.startsWith("/") ? path : `/${path}`;
|
|
2892
|
+
}
|
|
2893
|
+
function respondSuccess(res) {
|
|
2894
|
+
res.statusCode = 200;
|
|
2895
|
+
res.setHeader("content-type", "text/plain; charset=utf-8");
|
|
2896
|
+
res.end("success");
|
|
2897
|
+
}
|
|
2898
|
+
function buildSyntheticMessageId(rawBody, senderId, msgType) {
|
|
2899
|
+
return createHash("sha1").update(`${senderId}:${msgType}:${rawBody}`).digest("hex");
|
|
2900
|
+
}
|
|
2901
|
+
function extractXmlField(xml, field) {
|
|
2902
|
+
const cdataPattern = new RegExp(`<${field}><!\\[CDATA\\[(.*?)\\]\\]><\\/${field}>`, "s");
|
|
2903
|
+
const cdataMatch = xml.match(cdataPattern);
|
|
2904
|
+
if (cdataMatch?.[1]) {
|
|
2905
|
+
return cdataMatch[1].trim();
|
|
2906
|
+
}
|
|
2907
|
+
const textPattern = new RegExp(`<${field}>(.*?)<\\/${field}>`, "s");
|
|
2908
|
+
const textMatch = xml.match(textPattern);
|
|
2909
|
+
return textMatch?.[1]?.trim() ?? "";
|
|
2910
|
+
}
|
|
2911
|
+
async function readBody(req) {
|
|
2912
|
+
const chunks = [];
|
|
2913
|
+
return await new Promise((resolve, reject) => {
|
|
2914
|
+
req.on("data", (chunk) => {
|
|
2915
|
+
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
|
|
2916
|
+
});
|
|
2917
|
+
req.on("error", reject);
|
|
2918
|
+
req.on("end", () => {
|
|
2919
|
+
resolve(Buffer.concat(chunks).toString("utf8"));
|
|
2920
|
+
});
|
|
2921
|
+
});
|
|
2922
|
+
}
|
|
2923
|
+
function normalizeOutboundContent(msg) {
|
|
2924
|
+
const segments = [];
|
|
2925
|
+
if (typeof msg.content === "string" && msg.content.trim()) {
|
|
2926
|
+
segments.push(msg.content.trim());
|
|
2927
|
+
}
|
|
2928
|
+
if (Array.isArray(msg.media)) {
|
|
2929
|
+
for (const item of msg.media) {
|
|
2930
|
+
if (typeof item === "string" && item.trim()) {
|
|
2931
|
+
segments.push(item.trim());
|
|
2932
|
+
}
|
|
2933
|
+
}
|
|
2934
|
+
}
|
|
2935
|
+
return segments.join("\n").trim();
|
|
2936
|
+
}
|
|
2937
|
+
|
|
2671
2938
|
// src/channels/whatsapp.ts
|
|
2672
2939
|
import WebSocket from "ws";
|
|
2673
2940
|
var WhatsAppChannel = class extends BaseChannel {
|
|
@@ -2818,6 +3085,11 @@ var BUILTIN_CHANNEL_RUNTIMES = {
|
|
|
2818
3085
|
isEnabled: (config) => config.channels.dingtalk.enabled,
|
|
2819
3086
|
createChannel: (context) => new DingTalkChannel(context.config.channels.dingtalk, context.bus)
|
|
2820
3087
|
},
|
|
3088
|
+
wecom: {
|
|
3089
|
+
id: "wecom",
|
|
3090
|
+
isEnabled: (config) => config.channels.wecom.enabled,
|
|
3091
|
+
createChannel: (context) => new WeComChannel(context.config.channels.wecom, context.bus)
|
|
3092
|
+
},
|
|
2821
3093
|
email: {
|
|
2822
3094
|
id: "email",
|
|
2823
3095
|
isEnabled: (config) => config.channels.email.enabled,
|
|
@@ -2857,6 +3129,7 @@ export {
|
|
|
2857
3129
|
QQChannel,
|
|
2858
3130
|
SlackChannel,
|
|
2859
3131
|
TelegramChannel,
|
|
3132
|
+
WeComChannel,
|
|
2860
3133
|
WhatsAppChannel,
|
|
2861
3134
|
listBuiltinChannelRuntimes,
|
|
2862
3135
|
resolveBuiltinChannelRuntime
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nextclaw/channel-runtime",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.7",
|
|
4
4
|
"private": false,
|
|
5
5
|
"description": "Runtime implementations for NextClaw builtin channel plugins.",
|
|
6
6
|
"type": "module",
|
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
],
|
|
16
16
|
"dependencies": {
|
|
17
17
|
"@larksuiteoapi/node-sdk": "^1.58.0",
|
|
18
|
-
"@nextclaw/core": "^0.6.
|
|
18
|
+
"@nextclaw/core": "^0.6.21",
|
|
19
19
|
"@slack/socket-mode": "^1.3.3",
|
|
20
20
|
"@slack/web-api": "^7.6.0",
|
|
21
21
|
"dingtalk-stream": "^2.1.4",
|