@nextclaw/channel-extension-weixin 0.1.1
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 +21 -0
- package/README.md +5 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.js +6 -0
- package/dist/main.d.ts +1 -0
- package/dist/main.js +12 -0
- package/dist/services/weixin-api.service.d.ts +125 -0
- package/dist/services/weixin-api.service.js +182 -0
- package/dist/services/weixin-auth-capability.service.d.ts +20 -0
- package/dist/services/weixin-auth-capability.service.js +33 -0
- package/dist/services/weixin-channel-adapter.service.d.ts +53 -0
- package/dist/services/weixin-channel-adapter.service.js +303 -0
- package/dist/services/weixin-extension-runtime.service.d.ts +19 -0
- package/dist/services/weixin-extension-runtime.service.js +65 -0
- package/dist/services/weixin-login.service.d.ts +55 -0
- package/dist/services/weixin-login.service.js +203 -0
- package/dist/services/weixin-media-part-reader.service.js +192 -0
- package/dist/services/weixin-reply-chat.service.js +141 -0
- package/dist/services/weixin-typing-controller.service.js +109 -0
- package/dist/stores/weixin-account.store.d.ts +19 -0
- package/dist/stores/weixin-account.store.js +56 -0
- package/dist/types/weixin-extension.types.d.ts +43 -0
- package/dist/utils/weixin-config.utils.d.ts +69 -0
- package/dist/utils/weixin-config.utils.js +107 -0
- package/dist/utils/weixin-inbound-media.utils.js +215 -0
- package/dist/utils/weixin-media.utils.js +168 -0
- package/dist/utils/weixin-session-route.utils.js +38 -0
- package/nextclaw.extension.json +58 -0
- package/package.json +36 -0
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
import { basename, extname } from "node:path";
|
|
2
|
+
import { readFile } from "node:fs/promises";
|
|
3
|
+
//#region src/services/weixin-media-part-reader.service.ts
|
|
4
|
+
const IMAGE_FILE_EXTENSIONS = new Set([
|
|
5
|
+
".avif",
|
|
6
|
+
".bmp",
|
|
7
|
+
".gif",
|
|
8
|
+
".heic",
|
|
9
|
+
".jpeg",
|
|
10
|
+
".jpg",
|
|
11
|
+
".png",
|
|
12
|
+
".svg",
|
|
13
|
+
".webp"
|
|
14
|
+
]);
|
|
15
|
+
function readString(value) {
|
|
16
|
+
if (typeof value !== "string") return;
|
|
17
|
+
return value.trim() || void 0;
|
|
18
|
+
}
|
|
19
|
+
function normalizeMimeType(value) {
|
|
20
|
+
if (!value) return;
|
|
21
|
+
const [mimeType] = value.split(";", 1);
|
|
22
|
+
return mimeType?.trim().toLowerCase() || void 0;
|
|
23
|
+
}
|
|
24
|
+
function mimeTypeToExtension(mimeType) {
|
|
25
|
+
switch (mimeType) {
|
|
26
|
+
case "image/png": return ".png";
|
|
27
|
+
case "image/jpeg": return ".jpg";
|
|
28
|
+
case "image/gif": return ".gif";
|
|
29
|
+
case "image/webp": return ".webp";
|
|
30
|
+
case "image/svg+xml": return ".svg";
|
|
31
|
+
case "application/pdf": return ".pdf";
|
|
32
|
+
default: return ".bin";
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
function isImageMimeType(mimeType) {
|
|
36
|
+
return Boolean(mimeType?.startsWith("image/"));
|
|
37
|
+
}
|
|
38
|
+
function readPngDimensions(bytes) {
|
|
39
|
+
if (bytes.byteLength < 24) return null;
|
|
40
|
+
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
|
41
|
+
const signature = view.getUint32(0);
|
|
42
|
+
const ihdr = view.getUint32(12);
|
|
43
|
+
if (signature !== 2303741511 || ihdr !== 1229472850) return null;
|
|
44
|
+
return {
|
|
45
|
+
width: view.getUint32(16),
|
|
46
|
+
height: view.getUint32(20)
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
function readGifDimensions(bytes) {
|
|
50
|
+
if (bytes.byteLength < 10) return null;
|
|
51
|
+
const header = Buffer.from(bytes.subarray(0, 6)).toString("ascii");
|
|
52
|
+
if (header !== "GIF87a" && header !== "GIF89a") return null;
|
|
53
|
+
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
|
54
|
+
return {
|
|
55
|
+
width: view.getUint16(6, true),
|
|
56
|
+
height: view.getUint16(8, true)
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
function readJpegDimensions(bytes) {
|
|
60
|
+
if (bytes.byteLength < 4 || bytes[0] !== 255 || bytes[1] !== 216) return null;
|
|
61
|
+
let offset = 2;
|
|
62
|
+
while (offset + 9 < bytes.byteLength) {
|
|
63
|
+
if (bytes[offset] !== 255) {
|
|
64
|
+
offset += 1;
|
|
65
|
+
continue;
|
|
66
|
+
}
|
|
67
|
+
const marker = bytes[offset + 1];
|
|
68
|
+
offset += 2;
|
|
69
|
+
if (marker === 216 || marker === 217) continue;
|
|
70
|
+
if (offset + 2 > bytes.byteLength) return null;
|
|
71
|
+
const length = bytes[offset] << 8 | bytes[offset + 1];
|
|
72
|
+
if (length < 2 || offset + length > bytes.byteLength) return null;
|
|
73
|
+
if (marker >= 192 && marker <= 195 || marker >= 197 && marker <= 199 || marker >= 201 && marker <= 203 || marker >= 205 && marker <= 207) return {
|
|
74
|
+
height: bytes[offset + 3] << 8 | bytes[offset + 4],
|
|
75
|
+
width: bytes[offset + 5] << 8 | bytes[offset + 6]
|
|
76
|
+
};
|
|
77
|
+
offset += length;
|
|
78
|
+
}
|
|
79
|
+
return null;
|
|
80
|
+
}
|
|
81
|
+
function readWebpDimensions(bytes) {
|
|
82
|
+
if (bytes.byteLength < 30) return null;
|
|
83
|
+
const header = Buffer.from(bytes.subarray(0, 4)).toString("ascii");
|
|
84
|
+
const webp = Buffer.from(bytes.subarray(8, 12)).toString("ascii");
|
|
85
|
+
if (header !== "RIFF" || webp !== "WEBP") return null;
|
|
86
|
+
const chunk = Buffer.from(bytes.subarray(12, 16)).toString("ascii");
|
|
87
|
+
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
|
88
|
+
if (chunk === "VP8X" && bytes.byteLength >= 30) return {
|
|
89
|
+
width: 1 + (bytes[24] | bytes[25] << 8 | bytes[26] << 16),
|
|
90
|
+
height: 1 + (bytes[27] | bytes[28] << 8 | bytes[29] << 16)
|
|
91
|
+
};
|
|
92
|
+
if (chunk === "VP8 " && bytes.byteLength >= 30) return {
|
|
93
|
+
width: view.getUint16(26, true) & 16383,
|
|
94
|
+
height: view.getUint16(28, true) & 16383
|
|
95
|
+
};
|
|
96
|
+
if (chunk === "VP8L" && bytes.byteLength >= 25) {
|
|
97
|
+
const bits = bytes[21] | bytes[22] << 8 | bytes[23] << 16 | bytes[24] << 24;
|
|
98
|
+
return {
|
|
99
|
+
width: (bits & 16383) + 1,
|
|
100
|
+
height: (bits >> 14 & 16383) + 1
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
return null;
|
|
104
|
+
}
|
|
105
|
+
function readImageDimensions(bytes) {
|
|
106
|
+
return readPngDimensions(bytes) ?? readGifDimensions(bytes) ?? readJpegDimensions(bytes) ?? readWebpDimensions(bytes);
|
|
107
|
+
}
|
|
108
|
+
function readFileNameFromUrl(url) {
|
|
109
|
+
try {
|
|
110
|
+
return basename(new URL(url).pathname).trim() || void 0;
|
|
111
|
+
} catch {
|
|
112
|
+
return basename(url).trim() || void 0;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
function resolveFetchUrl(url) {
|
|
116
|
+
try {
|
|
117
|
+
return new URL(url).toString();
|
|
118
|
+
} catch {
|
|
119
|
+
const endpoint = process.env.NEXTCLAW_EXTENSION_ENDPOINT?.trim();
|
|
120
|
+
if (!endpoint) return url;
|
|
121
|
+
return new URL(url, endpoint.endsWith("/") ? endpoint : `${endpoint}/`).toString();
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
var WeixinMediaPartReader = class {
|
|
125
|
+
read = async (target, part) => {
|
|
126
|
+
if (part.contentBase64) {
|
|
127
|
+
const bytes = Buffer.from(part.contentBase64, "base64");
|
|
128
|
+
const mimeType = normalizeMimeType(part.mimeType);
|
|
129
|
+
const fileName = this.resolveFileName(part, mimeType);
|
|
130
|
+
const imageDimensions = this.resolveImageDimensions(bytes, fileName, mimeType);
|
|
131
|
+
return {
|
|
132
|
+
bytes,
|
|
133
|
+
fileName,
|
|
134
|
+
mimeType,
|
|
135
|
+
isImage: this.isImageFile(fileName, mimeType),
|
|
136
|
+
imageWidth: imageDimensions?.width,
|
|
137
|
+
imageHeight: imageDimensions?.height
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
if (part.assetUri) {
|
|
141
|
+
const contentPath = target.resolveAssetContentPath?.(part.assetUri)?.trim();
|
|
142
|
+
if (contentPath) {
|
|
143
|
+
const bytes = await readFile(contentPath);
|
|
144
|
+
const mimeType = normalizeMimeType(part.mimeType);
|
|
145
|
+
const fileName = this.resolveFileName(part, mimeType, basename(contentPath));
|
|
146
|
+
const imageDimensions = this.resolveImageDimensions(bytes, fileName, mimeType);
|
|
147
|
+
return {
|
|
148
|
+
bytes,
|
|
149
|
+
fileName,
|
|
150
|
+
mimeType,
|
|
151
|
+
isImage: this.isImageFile(fileName, mimeType),
|
|
152
|
+
imageWidth: imageDimensions?.width,
|
|
153
|
+
imageHeight: imageDimensions?.height
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
if (!part.url) throw new Error(`weixin send failed: asset "${part.assetUri}" is not readable`);
|
|
157
|
+
}
|
|
158
|
+
if (part.url) {
|
|
159
|
+
const response = await fetch(resolveFetchUrl(part.url));
|
|
160
|
+
if (!response.ok) throw new Error(`weixin send failed: unable to download ${part.url} (${response.status})`);
|
|
161
|
+
const bytes = new Uint8Array(await response.arrayBuffer());
|
|
162
|
+
const mimeType = normalizeMimeType(part.mimeType) ?? normalizeMimeType(response.headers.get("content-type") ?? void 0);
|
|
163
|
+
const fileName = this.resolveFileName(part, mimeType, readFileNameFromUrl(part.url));
|
|
164
|
+
const imageDimensions = this.resolveImageDimensions(bytes, fileName, mimeType);
|
|
165
|
+
return {
|
|
166
|
+
bytes,
|
|
167
|
+
fileName,
|
|
168
|
+
mimeType,
|
|
169
|
+
isImage: this.isImageFile(fileName, mimeType),
|
|
170
|
+
imageWidth: imageDimensions?.width,
|
|
171
|
+
imageHeight: imageDimensions?.height
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
throw new Error("weixin send failed: file part is missing content");
|
|
175
|
+
};
|
|
176
|
+
resolveFileName = (part, mimeType, fallbackName) => {
|
|
177
|
+
const explicitName = readString(part.name);
|
|
178
|
+
if (explicitName) return explicitName;
|
|
179
|
+
if (fallbackName) return fallbackName;
|
|
180
|
+
return `attachment${mimeTypeToExtension(mimeType)}`;
|
|
181
|
+
};
|
|
182
|
+
isImageFile = (fileName, mimeType) => {
|
|
183
|
+
if (isImageMimeType(mimeType)) return true;
|
|
184
|
+
return IMAGE_FILE_EXTENSIONS.has(extname(fileName).toLowerCase());
|
|
185
|
+
};
|
|
186
|
+
resolveImageDimensions = (bytes, fileName, mimeType) => {
|
|
187
|
+
if (!this.isImageFile(fileName, mimeType)) return null;
|
|
188
|
+
return readImageDimensions(bytes);
|
|
189
|
+
};
|
|
190
|
+
};
|
|
191
|
+
//#endregion
|
|
192
|
+
export { WeixinMediaPartReader };
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import { sendWeixinFileMessage, sendWeixinImageMessage } from "../utils/weixin-media.utils.js";
|
|
2
|
+
import { WeixinMediaPartReader } from "./weixin-media-part-reader.service.js";
|
|
3
|
+
import "@nextclaw/ncp-toolkit";
|
|
4
|
+
import { NcpEventType } from "@nextclaw/ncp";
|
|
5
|
+
//#region src/services/weixin-reply-chat.service.ts
|
|
6
|
+
const TERMINAL_NCP_EVENT_TYPES = new Set([
|
|
7
|
+
NcpEventType.MessageCompleted,
|
|
8
|
+
NcpEventType.MessageFailed,
|
|
9
|
+
NcpEventType.RunFinished,
|
|
10
|
+
NcpEventType.RunError
|
|
11
|
+
]);
|
|
12
|
+
var NcpEventQueue = class {
|
|
13
|
+
events = [];
|
|
14
|
+
waiting = null;
|
|
15
|
+
closed = false;
|
|
16
|
+
push = (event) => {
|
|
17
|
+
if (this.closed) return;
|
|
18
|
+
const waiting = this.waiting;
|
|
19
|
+
if (waiting) {
|
|
20
|
+
this.waiting = null;
|
|
21
|
+
waiting({
|
|
22
|
+
value: event,
|
|
23
|
+
done: false
|
|
24
|
+
});
|
|
25
|
+
return;
|
|
26
|
+
}
|
|
27
|
+
this.events.push(event);
|
|
28
|
+
};
|
|
29
|
+
close = () => {
|
|
30
|
+
this.closed = true;
|
|
31
|
+
const waiting = this.waiting;
|
|
32
|
+
if (waiting) {
|
|
33
|
+
this.waiting = null;
|
|
34
|
+
waiting({
|
|
35
|
+
value: void 0,
|
|
36
|
+
done: true
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
};
|
|
40
|
+
[Symbol.asyncIterator] = () => ({ next: async () => {
|
|
41
|
+
const event = this.events.shift();
|
|
42
|
+
if (event) return {
|
|
43
|
+
value: event,
|
|
44
|
+
done: false
|
|
45
|
+
};
|
|
46
|
+
if (this.closed) return {
|
|
47
|
+
value: void 0,
|
|
48
|
+
done: true
|
|
49
|
+
};
|
|
50
|
+
return await new Promise((resolve) => {
|
|
51
|
+
this.waiting = resolve;
|
|
52
|
+
});
|
|
53
|
+
} });
|
|
54
|
+
};
|
|
55
|
+
function renderPartText(part) {
|
|
56
|
+
switch (part.type) {
|
|
57
|
+
case "text":
|
|
58
|
+
case "rich-text": return part.text;
|
|
59
|
+
case "source": return [
|
|
60
|
+
part.title ?? "",
|
|
61
|
+
part.url ?? "",
|
|
62
|
+
part.snippet ?? ""
|
|
63
|
+
].filter(Boolean).join("\n");
|
|
64
|
+
case "card": return (typeof part.payload.title === "string" ? part.payload.title.trim() : "") || JSON.stringify(part.payload);
|
|
65
|
+
case "action": return part.label.trim();
|
|
66
|
+
case "step-start": return part.title?.trim() ?? "";
|
|
67
|
+
default: return "";
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
var WeixinReplyChat = class {
|
|
71
|
+
mediaPartReader = new WeixinMediaPartReader();
|
|
72
|
+
constructor(deps) {
|
|
73
|
+
this.deps = deps;
|
|
74
|
+
}
|
|
75
|
+
startTyping = async (target) => {
|
|
76
|
+
const account = this.deps.resolveAccount(target);
|
|
77
|
+
const contextToken = this.deps.resolveContextToken(target, account.accountId);
|
|
78
|
+
if (!contextToken) return;
|
|
79
|
+
await this.deps.typingController.start({
|
|
80
|
+
accountId: account.accountId,
|
|
81
|
+
userId: target.conversationId,
|
|
82
|
+
contextToken,
|
|
83
|
+
baseUrl: account.baseUrl,
|
|
84
|
+
token: account.token
|
|
85
|
+
});
|
|
86
|
+
};
|
|
87
|
+
stopTyping = async (target) => {
|
|
88
|
+
const account = this.deps.resolveAccount(target);
|
|
89
|
+
await this.deps.typingController.stop({
|
|
90
|
+
accountId: account.accountId,
|
|
91
|
+
userId: target.conversationId
|
|
92
|
+
});
|
|
93
|
+
};
|
|
94
|
+
sendError = async (target, message) => {
|
|
95
|
+
if (message.trim()) await this.sendText(target, message);
|
|
96
|
+
};
|
|
97
|
+
sendPart = async (target, part) => {
|
|
98
|
+
if (part.type === "file") {
|
|
99
|
+
await this.sendFilePart(target, part);
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
const text = renderPartText(part);
|
|
103
|
+
if (text.trim()) await this.sendText(target, text);
|
|
104
|
+
};
|
|
105
|
+
sendText = async (target, text) => {
|
|
106
|
+
const account = this.deps.resolveAccount(target);
|
|
107
|
+
await this.deps.sendText({
|
|
108
|
+
account,
|
|
109
|
+
conversationId: target.conversationId,
|
|
110
|
+
text,
|
|
111
|
+
contextToken: this.deps.resolveContextToken(target, account.accountId)
|
|
112
|
+
});
|
|
113
|
+
};
|
|
114
|
+
sendFilePart = async (target, part) => {
|
|
115
|
+
const media = await this.mediaPartReader.read(target, part);
|
|
116
|
+
const account = this.deps.resolveAccount(target);
|
|
117
|
+
const contextToken = this.deps.resolveContextToken(target, account.accountId);
|
|
118
|
+
if (media.isImage) {
|
|
119
|
+
await sendWeixinImageMessage({
|
|
120
|
+
baseUrl: account.baseUrl,
|
|
121
|
+
token: account.token,
|
|
122
|
+
toUserId: target.conversationId,
|
|
123
|
+
bytes: media.bytes,
|
|
124
|
+
width: media.imageWidth,
|
|
125
|
+
height: media.imageHeight,
|
|
126
|
+
contextToken
|
|
127
|
+
});
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
await sendWeixinFileMessage({
|
|
131
|
+
baseUrl: account.baseUrl,
|
|
132
|
+
token: account.token,
|
|
133
|
+
toUserId: target.conversationId,
|
|
134
|
+
fileName: media.fileName,
|
|
135
|
+
bytes: media.bytes,
|
|
136
|
+
contextToken
|
|
137
|
+
});
|
|
138
|
+
};
|
|
139
|
+
};
|
|
140
|
+
//#endregion
|
|
141
|
+
export { NcpEventQueue, TERMINAL_NCP_EVENT_TYPES, WeixinReplyChat };
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
//#region src/services/weixin-typing-controller.service.ts
|
|
2
|
+
function buildTypingKey(accountId, userId) {
|
|
3
|
+
return `${accountId}:${userId}`;
|
|
4
|
+
}
|
|
5
|
+
var WeixinTypingController = class {
|
|
6
|
+
heartbeatMs;
|
|
7
|
+
ticketTtlMs;
|
|
8
|
+
ticketCache = /* @__PURE__ */ new Map();
|
|
9
|
+
activeSessions = /* @__PURE__ */ new Map();
|
|
10
|
+
sessionSequences = /* @__PURE__ */ new Map();
|
|
11
|
+
fetchTicket;
|
|
12
|
+
sendTyping;
|
|
13
|
+
constructor(options) {
|
|
14
|
+
const { fetchTicket, heartbeatMs, sendTyping, ticketTtlMs } = options;
|
|
15
|
+
this.heartbeatMs = Math.max(1e3, Math.trunc(heartbeatMs ?? 5e3));
|
|
16
|
+
this.ticketTtlMs = Math.max(this.heartbeatMs, Math.trunc(ticketTtlMs ?? 1440 * 60 * 1e3));
|
|
17
|
+
this.fetchTicket = fetchTicket;
|
|
18
|
+
this.sendTyping = sendTyping;
|
|
19
|
+
}
|
|
20
|
+
start = async (runtime) => {
|
|
21
|
+
const key = buildTypingKey(runtime.accountId, runtime.userId);
|
|
22
|
+
const sequence = this.bumpSequence(key);
|
|
23
|
+
await this.clearActiveSession({
|
|
24
|
+
accountId: runtime.accountId,
|
|
25
|
+
userId: runtime.userId,
|
|
26
|
+
sendCancel: false
|
|
27
|
+
});
|
|
28
|
+
const ticket = await this.getTicket(runtime);
|
|
29
|
+
if (!ticket || this.sessionSequences.get(key) !== sequence) return;
|
|
30
|
+
await this.sendTypingSafe({
|
|
31
|
+
...runtime,
|
|
32
|
+
ticket,
|
|
33
|
+
status: 1
|
|
34
|
+
});
|
|
35
|
+
if (this.sessionSequences.get(key) !== sequence) return;
|
|
36
|
+
const heartbeat = setInterval(() => {
|
|
37
|
+
const active = this.activeSessions.get(key);
|
|
38
|
+
if (!active || active.sequence !== sequence) return;
|
|
39
|
+
this.sendTypingSafe({
|
|
40
|
+
...active.runtime,
|
|
41
|
+
ticket: active.ticket,
|
|
42
|
+
status: 1
|
|
43
|
+
});
|
|
44
|
+
}, this.heartbeatMs);
|
|
45
|
+
this.activeSessions.set(key, {
|
|
46
|
+
heartbeat,
|
|
47
|
+
ticket,
|
|
48
|
+
runtime,
|
|
49
|
+
sequence
|
|
50
|
+
});
|
|
51
|
+
};
|
|
52
|
+
stop = async (params) => {
|
|
53
|
+
const key = buildTypingKey(params.accountId, params.userId);
|
|
54
|
+
this.bumpSequence(key);
|
|
55
|
+
await this.clearActiveSession(params);
|
|
56
|
+
};
|
|
57
|
+
stopAll = async () => {
|
|
58
|
+
const sessions = Array.from(this.activeSessions.values());
|
|
59
|
+
this.activeSessions.clear();
|
|
60
|
+
for (const session of sessions) {
|
|
61
|
+
this.bumpSequence(buildTypingKey(session.runtime.accountId, session.runtime.userId));
|
|
62
|
+
clearInterval(session.heartbeat);
|
|
63
|
+
}
|
|
64
|
+
await Promise.allSettled(sessions.map(async (session) => {
|
|
65
|
+
await this.sendTypingSafe({
|
|
66
|
+
...session.runtime,
|
|
67
|
+
ticket: session.ticket,
|
|
68
|
+
status: 2
|
|
69
|
+
});
|
|
70
|
+
}));
|
|
71
|
+
};
|
|
72
|
+
bumpSequence = (key) => {
|
|
73
|
+
const next = (this.sessionSequences.get(key) ?? 0) + 1;
|
|
74
|
+
this.sessionSequences.set(key, next);
|
|
75
|
+
return next;
|
|
76
|
+
};
|
|
77
|
+
clearActiveSession = async (params) => {
|
|
78
|
+
const key = buildTypingKey(params.accountId, params.userId);
|
|
79
|
+
const active = this.activeSessions.get(key);
|
|
80
|
+
if (!active) return;
|
|
81
|
+
clearInterval(active.heartbeat);
|
|
82
|
+
this.activeSessions.delete(key);
|
|
83
|
+
if (params.sendCancel === false) return;
|
|
84
|
+
await this.sendTypingSafe({
|
|
85
|
+
...active.runtime,
|
|
86
|
+
ticket: active.ticket,
|
|
87
|
+
status: 2
|
|
88
|
+
});
|
|
89
|
+
};
|
|
90
|
+
getTicket = async (runtime) => {
|
|
91
|
+
const key = buildTypingKey(runtime.accountId, runtime.userId);
|
|
92
|
+
const cached = this.ticketCache.get(key);
|
|
93
|
+
if (cached && cached.expiresAtMs > Date.now()) return cached.ticket;
|
|
94
|
+
const ticket = (await this.fetchTicket(runtime))?.trim();
|
|
95
|
+
if (!ticket) return;
|
|
96
|
+
this.ticketCache.set(key, {
|
|
97
|
+
ticket,
|
|
98
|
+
expiresAtMs: Date.now() + this.ticketTtlMs
|
|
99
|
+
});
|
|
100
|
+
return ticket;
|
|
101
|
+
};
|
|
102
|
+
sendTypingSafe = async (params) => {
|
|
103
|
+
try {
|
|
104
|
+
await this.sendTyping(params);
|
|
105
|
+
} catch {}
|
|
106
|
+
};
|
|
107
|
+
};
|
|
108
|
+
//#endregion
|
|
109
|
+
export { WeixinTypingController };
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
//#region src/stores/weixin-account.store.d.ts
|
|
2
|
+
type StoredWeixinAccount = {
|
|
3
|
+
accountId: string;
|
|
4
|
+
token: string;
|
|
5
|
+
baseUrl?: string;
|
|
6
|
+
userId?: string;
|
|
7
|
+
savedAt?: string;
|
|
8
|
+
};
|
|
9
|
+
type WeixinAccountStore = {
|
|
10
|
+
listAccountIds: () => string[];
|
|
11
|
+
loadAccount: (accountId: string) => StoredWeixinAccount | null;
|
|
12
|
+
saveAccount: (account: StoredWeixinAccount) => void;
|
|
13
|
+
deleteAccount: (accountId: string) => void;
|
|
14
|
+
loadCursor: (accountId: string) => string | undefined;
|
|
15
|
+
saveCursor: (accountId: string, cursor: string | undefined) => void;
|
|
16
|
+
deleteCursor: (accountId: string) => void;
|
|
17
|
+
};
|
|
18
|
+
//#endregion
|
|
19
|
+
export { WeixinAccountStore };
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { join, resolve } from "node:path";
|
|
4
|
+
//#region src/stores/weixin-account.store.ts
|
|
5
|
+
function resolveNextclawHome() {
|
|
6
|
+
const override = process.env.NEXTCLAW_HOME?.trim();
|
|
7
|
+
return resolve(override || join(homedir(), ".nextclaw"));
|
|
8
|
+
}
|
|
9
|
+
function resolveWeixinDataDir() {
|
|
10
|
+
return join(resolveNextclawHome(), "channels", "weixin");
|
|
11
|
+
}
|
|
12
|
+
function resolveAccountsDir() {
|
|
13
|
+
return join(resolveWeixinDataDir(), "accounts");
|
|
14
|
+
}
|
|
15
|
+
function resolveCursorsDir() {
|
|
16
|
+
return join(resolveWeixinDataDir(), "cursors");
|
|
17
|
+
}
|
|
18
|
+
function toFileName(accountId) {
|
|
19
|
+
return `${encodeURIComponent(accountId)}.json`;
|
|
20
|
+
}
|
|
21
|
+
function readJsonFile(filePath) {
|
|
22
|
+
if (!existsSync(filePath)) return null;
|
|
23
|
+
try {
|
|
24
|
+
return JSON.parse(readFileSync(filePath, "utf-8"));
|
|
25
|
+
} catch {
|
|
26
|
+
return null;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
var FileWeixinAccountStore = class {
|
|
30
|
+
listAccountIds = () => {
|
|
31
|
+
if (!existsSync(resolveAccountsDir())) return [];
|
|
32
|
+
return readdirSync(resolveAccountsDir()).filter((entry) => entry.endsWith(".json")).map((entry) => decodeURIComponent(entry.slice(0, -5))).filter(Boolean);
|
|
33
|
+
};
|
|
34
|
+
loadAccount = (accountId) => {
|
|
35
|
+
return readJsonFile(join(resolveAccountsDir(), toFileName(accountId)));
|
|
36
|
+
};
|
|
37
|
+
saveAccount = (account) => {
|
|
38
|
+
mkdirSync(resolveAccountsDir(), { recursive: true });
|
|
39
|
+
writeFileSync(join(resolveAccountsDir(), toFileName(account.accountId)), JSON.stringify(account, null, 2));
|
|
40
|
+
};
|
|
41
|
+
deleteAccount = (accountId) => {
|
|
42
|
+
rmSync(join(resolveAccountsDir(), toFileName(accountId)), { force: true });
|
|
43
|
+
};
|
|
44
|
+
loadCursor = (accountId) => {
|
|
45
|
+
return readJsonFile(join(resolveCursorsDir(), toFileName(accountId)))?.cursor?.trim() || void 0;
|
|
46
|
+
};
|
|
47
|
+
saveCursor = (accountId, cursor) => {
|
|
48
|
+
mkdirSync(resolveCursorsDir(), { recursive: true });
|
|
49
|
+
writeFileSync(join(resolveCursorsDir(), toFileName(accountId)), JSON.stringify({ cursor: cursor ?? "" }, null, 2));
|
|
50
|
+
};
|
|
51
|
+
deleteCursor = (accountId) => {
|
|
52
|
+
rmSync(join(resolveCursorsDir(), toFileName(accountId)), { force: true });
|
|
53
|
+
};
|
|
54
|
+
};
|
|
55
|
+
//#endregion
|
|
56
|
+
export { FileWeixinAccountStore };
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { NcpEndpointEvent } from "@nextclaw/ncp";
|
|
2
|
+
import { ChannelSubmittedAttachment } from "@nextclaw/extension-sdk";
|
|
3
|
+
|
|
4
|
+
//#region src/types/weixin-extension.types.d.ts
|
|
5
|
+
type WeixinAccountConfig = {
|
|
6
|
+
enabled?: boolean;
|
|
7
|
+
baseUrl?: string;
|
|
8
|
+
allowFrom?: string[];
|
|
9
|
+
};
|
|
10
|
+
type WeixinChannelConfig = {
|
|
11
|
+
enabled?: boolean;
|
|
12
|
+
defaultAccountId?: string;
|
|
13
|
+
baseUrl?: string;
|
|
14
|
+
pollTimeoutMs?: number;
|
|
15
|
+
allowFrom?: string[];
|
|
16
|
+
accounts?: Record<string, WeixinAccountConfig>;
|
|
17
|
+
};
|
|
18
|
+
type WeixinInboundMessage = {
|
|
19
|
+
conversationId: string;
|
|
20
|
+
senderId: string;
|
|
21
|
+
text: string;
|
|
22
|
+
attachments?: ChannelSubmittedAttachment[];
|
|
23
|
+
accountId?: string;
|
|
24
|
+
contextToken?: string;
|
|
25
|
+
raw?: unknown;
|
|
26
|
+
};
|
|
27
|
+
type WeixinRuntimeAccount = {
|
|
28
|
+
accountId: string;
|
|
29
|
+
token: string;
|
|
30
|
+
enabled: boolean;
|
|
31
|
+
baseUrl: string;
|
|
32
|
+
pollTimeoutMs: number;
|
|
33
|
+
allowFrom: string[];
|
|
34
|
+
};
|
|
35
|
+
type WeixinChannelAdapter = {
|
|
36
|
+
configure: (config: WeixinChannelConfig) => Promise<void>;
|
|
37
|
+
start: () => Promise<void>;
|
|
38
|
+
stop: () => Promise<void>;
|
|
39
|
+
onMessage: (handler: (message: WeixinInboundMessage) => void | Promise<void>) => () => void;
|
|
40
|
+
sendNcpEvent: (event: NcpEndpointEvent) => Promise<void>;
|
|
41
|
+
};
|
|
42
|
+
//#endregion
|
|
43
|
+
export { WeixinAccountConfig, WeixinChannelAdapter, WeixinChannelConfig, WeixinInboundMessage, WeixinRuntimeAccount };
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
//#region src/utils/weixin-config.utils.d.ts
|
|
2
|
+
declare const WEIXIN_EXTENSION_ID = "nextclaw-channel-extension-weixin";
|
|
3
|
+
declare const WEIXIN_CHANNEL_ID = "weixin";
|
|
4
|
+
declare const DEFAULT_WEIXIN_BASE_URL = "https://ilinkai.weixin.qq.com";
|
|
5
|
+
declare const WEIXIN_CHANNEL_CONFIG_SCHEMA: {
|
|
6
|
+
readonly type: "object";
|
|
7
|
+
readonly additionalProperties: false;
|
|
8
|
+
readonly properties: {
|
|
9
|
+
readonly enabled: {
|
|
10
|
+
readonly type: "boolean";
|
|
11
|
+
};
|
|
12
|
+
readonly defaultAccountId: {
|
|
13
|
+
readonly type: "string";
|
|
14
|
+
};
|
|
15
|
+
readonly baseUrl: {
|
|
16
|
+
readonly type: "string";
|
|
17
|
+
};
|
|
18
|
+
readonly pollTimeoutMs: {
|
|
19
|
+
readonly type: "number";
|
|
20
|
+
};
|
|
21
|
+
readonly allowFrom: {
|
|
22
|
+
readonly type: "array";
|
|
23
|
+
readonly items: {
|
|
24
|
+
readonly type: "string";
|
|
25
|
+
};
|
|
26
|
+
};
|
|
27
|
+
readonly accounts: {
|
|
28
|
+
readonly type: "object";
|
|
29
|
+
readonly additionalProperties: {
|
|
30
|
+
readonly type: "object";
|
|
31
|
+
readonly additionalProperties: false;
|
|
32
|
+
readonly properties: {
|
|
33
|
+
readonly enabled: {
|
|
34
|
+
readonly type: "boolean";
|
|
35
|
+
};
|
|
36
|
+
readonly baseUrl: {
|
|
37
|
+
readonly type: "string";
|
|
38
|
+
};
|
|
39
|
+
readonly allowFrom: {
|
|
40
|
+
readonly type: "array";
|
|
41
|
+
readonly items: {
|
|
42
|
+
readonly type: "string";
|
|
43
|
+
};
|
|
44
|
+
};
|
|
45
|
+
};
|
|
46
|
+
};
|
|
47
|
+
};
|
|
48
|
+
};
|
|
49
|
+
};
|
|
50
|
+
declare const WEIXIN_CHANNEL_CONFIG_UI_HINTS: {
|
|
51
|
+
readonly enabled: {
|
|
52
|
+
readonly label: "Enabled";
|
|
53
|
+
};
|
|
54
|
+
readonly defaultAccountId: {
|
|
55
|
+
readonly label: "Default Account ID";
|
|
56
|
+
};
|
|
57
|
+
readonly baseUrl: {
|
|
58
|
+
readonly label: "API Base URL";
|
|
59
|
+
};
|
|
60
|
+
readonly pollTimeoutMs: {
|
|
61
|
+
readonly label: "Long Poll Timeout (ms)";
|
|
62
|
+
readonly advanced: true;
|
|
63
|
+
};
|
|
64
|
+
readonly allowFrom: {
|
|
65
|
+
readonly label: "Allow From";
|
|
66
|
+
};
|
|
67
|
+
};
|
|
68
|
+
//#endregion
|
|
69
|
+
export { DEFAULT_WEIXIN_BASE_URL, WEIXIN_CHANNEL_CONFIG_SCHEMA, WEIXIN_CHANNEL_CONFIG_UI_HINTS, WEIXIN_CHANNEL_ID, WEIXIN_EXTENSION_ID };
|