@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 CHANGED
@@ -7,3 +7,4 @@ This package is consumed by channel plugin packages such as:
7
7
  - `@nextclaw/channel-plugin-telegram`
8
8
  - `@nextclaw/channel-plugin-discord`
9
9
  - `@nextclaw/channel-plugin-email`
10
+ - `@nextclaw/channel-plugin-wecom`
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.6",
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.20",
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",