@aster110/cc2wechat 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/.claude-plugin/plugin.json +6 -0
- package/.mcp.json +6 -0
- package/EXPERIENCE.md +148 -0
- package/README.md +66 -0
- package/dist/auth.d.ts +15 -0
- package/dist/auth.js +230 -0
- package/dist/auth.js.map +1 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +123 -0
- package/dist/cli.js.map +1 -0
- package/dist/server.d.ts +2 -0
- package/dist/server.js +412 -0
- package/dist/server.js.map +1 -0
- package/dist/store.d.ts +12 -0
- package/dist/store.js +61 -0
- package/dist/store.js.map +1 -0
- package/dist/types.d.ts +126 -0
- package/dist/types.js +23 -0
- package/dist/types.js.map +1 -0
- package/dist/wechat-api.d.ts +33 -0
- package/dist/wechat-api.js +313 -0
- package/dist/wechat-api.js.map +1 -0
- package/package.json +28 -0
- package/skills/wechat-reply/SKILL.md +21 -0
- package/src/auth.ts +258 -0
- package/src/cli.ts +133 -0
- package/src/qrcode-terminal.d.ts +9 -0
- package/src/server.ts +479 -0
- package/src/store.ts +79 -0
- package/src/types.ts +144 -0
- package/src/wechat-api.ts +405 -0
- package/tsconfig.json +17 -0
package/src/types.ts
ADDED
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
/** Common request metadata attached to every CGI request. */
|
|
2
|
+
export interface BaseInfo {
|
|
3
|
+
channel_version?: string;
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
export const MessageType = {
|
|
7
|
+
NONE: 0,
|
|
8
|
+
USER: 1,
|
|
9
|
+
BOT: 2,
|
|
10
|
+
} as const;
|
|
11
|
+
|
|
12
|
+
export const MessageItemType = {
|
|
13
|
+
NONE: 0,
|
|
14
|
+
TEXT: 1,
|
|
15
|
+
IMAGE: 2,
|
|
16
|
+
VOICE: 3,
|
|
17
|
+
FILE: 4,
|
|
18
|
+
VIDEO: 5,
|
|
19
|
+
} as const;
|
|
20
|
+
|
|
21
|
+
export const MessageState = {
|
|
22
|
+
NEW: 0,
|
|
23
|
+
GENERATING: 1,
|
|
24
|
+
FINISH: 2,
|
|
25
|
+
} as const;
|
|
26
|
+
|
|
27
|
+
export const TypingStatus = {
|
|
28
|
+
TYPING: 1,
|
|
29
|
+
CANCEL: 2,
|
|
30
|
+
} as const;
|
|
31
|
+
|
|
32
|
+
export interface TextItem {
|
|
33
|
+
text?: string;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface CDNMedia {
|
|
37
|
+
encrypt_query_param?: string;
|
|
38
|
+
aes_key?: string;
|
|
39
|
+
encrypt_type?: number;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface ImageItem {
|
|
43
|
+
media?: CDNMedia;
|
|
44
|
+
thumb_media?: CDNMedia;
|
|
45
|
+
aeskey?: string;
|
|
46
|
+
url?: string;
|
|
47
|
+
mid_size?: number;
|
|
48
|
+
thumb_size?: number;
|
|
49
|
+
thumb_height?: number;
|
|
50
|
+
thumb_width?: number;
|
|
51
|
+
hd_size?: number;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export interface VoiceItem {
|
|
55
|
+
media?: CDNMedia;
|
|
56
|
+
encode_type?: number;
|
|
57
|
+
bits_per_sample?: number;
|
|
58
|
+
sample_rate?: number;
|
|
59
|
+
playtime?: number;
|
|
60
|
+
text?: string;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export interface FileItem {
|
|
64
|
+
media?: CDNMedia;
|
|
65
|
+
file_name?: string;
|
|
66
|
+
md5?: string;
|
|
67
|
+
len?: string;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export interface VideoItem {
|
|
71
|
+
media?: CDNMedia;
|
|
72
|
+
video_size?: number;
|
|
73
|
+
play_length?: number;
|
|
74
|
+
video_md5?: string;
|
|
75
|
+
thumb_media?: CDNMedia;
|
|
76
|
+
thumb_size?: number;
|
|
77
|
+
thumb_height?: number;
|
|
78
|
+
thumb_width?: number;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export interface RefMessage {
|
|
82
|
+
message_item?: MessageItem;
|
|
83
|
+
title?: string;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export interface MessageItem {
|
|
87
|
+
type?: number;
|
|
88
|
+
create_time_ms?: number;
|
|
89
|
+
update_time_ms?: number;
|
|
90
|
+
is_completed?: boolean;
|
|
91
|
+
msg_id?: string;
|
|
92
|
+
ref_msg?: RefMessage;
|
|
93
|
+
text_item?: TextItem;
|
|
94
|
+
image_item?: ImageItem;
|
|
95
|
+
voice_item?: VoiceItem;
|
|
96
|
+
file_item?: FileItem;
|
|
97
|
+
video_item?: VideoItem;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export interface WeixinMessage {
|
|
101
|
+
seq?: number;
|
|
102
|
+
message_id?: number;
|
|
103
|
+
from_user_id?: string;
|
|
104
|
+
to_user_id?: string;
|
|
105
|
+
client_id?: string;
|
|
106
|
+
create_time_ms?: number;
|
|
107
|
+
update_time_ms?: number;
|
|
108
|
+
delete_time_ms?: number;
|
|
109
|
+
session_id?: string;
|
|
110
|
+
group_id?: string;
|
|
111
|
+
message_type?: number;
|
|
112
|
+
message_state?: number;
|
|
113
|
+
item_list?: MessageItem[];
|
|
114
|
+
context_token?: string;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export interface GetUpdatesReq {
|
|
118
|
+
get_updates_buf?: string;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export interface GetUpdatesResp {
|
|
122
|
+
ret?: number;
|
|
123
|
+
errcode?: number;
|
|
124
|
+
errmsg?: string;
|
|
125
|
+
msgs?: WeixinMessage[];
|
|
126
|
+
get_updates_buf?: string;
|
|
127
|
+
longpolling_timeout_ms?: number;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export interface SendMessageReq {
|
|
131
|
+
msg?: WeixinMessage;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export interface SendTypingReq {
|
|
135
|
+
ilink_user_id?: string;
|
|
136
|
+
typing_ticket?: string;
|
|
137
|
+
status?: number;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export interface GetConfigResp {
|
|
141
|
+
ret?: number;
|
|
142
|
+
errmsg?: string;
|
|
143
|
+
typing_ticket?: string;
|
|
144
|
+
}
|
|
@@ -0,0 +1,405 @@
|
|
|
1
|
+
import crypto from 'node:crypto';
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import type {
|
|
5
|
+
BaseInfo,
|
|
6
|
+
GetUpdatesResp,
|
|
7
|
+
SendMessageReq,
|
|
8
|
+
SendTypingReq,
|
|
9
|
+
GetConfigResp,
|
|
10
|
+
} from './types.js';
|
|
11
|
+
|
|
12
|
+
const BASE_URL = 'https://ilinkai.weixin.qq.com';
|
|
13
|
+
const CDN_BASE_URL = 'https://novac2c.cdn.weixin.qq.com/c2c';
|
|
14
|
+
const CHANNEL_VERSION = '1.0.0';
|
|
15
|
+
|
|
16
|
+
const DEFAULT_LONG_POLL_TIMEOUT_MS = 35_000;
|
|
17
|
+
const DEFAULT_API_TIMEOUT_MS = 15_000;
|
|
18
|
+
const DEFAULT_CONFIG_TIMEOUT_MS = 10_000;
|
|
19
|
+
|
|
20
|
+
function buildBaseInfo(): BaseInfo {
|
|
21
|
+
return { channel_version: CHANNEL_VERSION };
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** X-WECHAT-UIN header: random uint32 -> decimal string -> base64. */
|
|
25
|
+
function randomWechatUin(): string {
|
|
26
|
+
const uint32 = crypto.randomBytes(4).readUInt32BE(0);
|
|
27
|
+
return Buffer.from(String(uint32), 'utf-8').toString('base64');
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function buildHeaders(token?: string, body?: string): Record<string, string> {
|
|
31
|
+
const headers: Record<string, string> = {
|
|
32
|
+
'Content-Type': 'application/json',
|
|
33
|
+
AuthorizationType: 'ilink_bot_token',
|
|
34
|
+
'X-WECHAT-UIN': randomWechatUin(),
|
|
35
|
+
};
|
|
36
|
+
if (token) headers['Authorization'] = `Bearer ${token}`;
|
|
37
|
+
if (body) headers['Content-Length'] = String(Buffer.byteLength(body, 'utf-8'));
|
|
38
|
+
return headers;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function ensureTrailingSlash(url: string): string {
|
|
42
|
+
return url.endsWith('/') ? url : `${url}/`;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Common POST wrapper with timeout + abort.
|
|
47
|
+
*/
|
|
48
|
+
async function apiFetch(params: {
|
|
49
|
+
baseUrl?: string;
|
|
50
|
+
endpoint: string;
|
|
51
|
+
body: string;
|
|
52
|
+
token?: string;
|
|
53
|
+
timeoutMs: number;
|
|
54
|
+
label: string;
|
|
55
|
+
}): Promise<string> {
|
|
56
|
+
const base = ensureTrailingSlash(params.baseUrl ?? BASE_URL);
|
|
57
|
+
const url = new URL(params.endpoint, base);
|
|
58
|
+
const headers = buildHeaders(params.token, params.body);
|
|
59
|
+
|
|
60
|
+
const controller = new AbortController();
|
|
61
|
+
const timer = setTimeout(() => controller.abort(), params.timeoutMs);
|
|
62
|
+
try {
|
|
63
|
+
const res = await fetch(url.toString(), {
|
|
64
|
+
method: 'POST',
|
|
65
|
+
headers,
|
|
66
|
+
body: params.body,
|
|
67
|
+
signal: controller.signal,
|
|
68
|
+
});
|
|
69
|
+
clearTimeout(timer);
|
|
70
|
+
const rawText = await res.text();
|
|
71
|
+
if (!res.ok) {
|
|
72
|
+
throw new Error(`${params.label} ${res.status}: ${rawText}`);
|
|
73
|
+
}
|
|
74
|
+
return rawText;
|
|
75
|
+
} catch (err) {
|
|
76
|
+
clearTimeout(timer);
|
|
77
|
+
throw err;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// ---------------------------------------------------------------------------
|
|
82
|
+
// QR Login
|
|
83
|
+
// ---------------------------------------------------------------------------
|
|
84
|
+
|
|
85
|
+
export interface QRCodeResponse {
|
|
86
|
+
qrcode: string;
|
|
87
|
+
qrcode_img_content: string;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export interface QRStatusResponse {
|
|
91
|
+
status: 'wait' | 'scaned' | 'confirmed' | 'expired';
|
|
92
|
+
bot_token?: string;
|
|
93
|
+
ilink_bot_id?: string;
|
|
94
|
+
baseurl?: string;
|
|
95
|
+
ilink_user_id?: string;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const QR_LONG_POLL_TIMEOUT_MS = 35_000;
|
|
99
|
+
|
|
100
|
+
export async function getQRCode(baseUrl?: string, botType = '3'): Promise<QRCodeResponse> {
|
|
101
|
+
const base = ensureTrailingSlash(baseUrl ?? BASE_URL);
|
|
102
|
+
const url = new URL(`ilink/bot/get_bot_qrcode?bot_type=${encodeURIComponent(botType)}`, base);
|
|
103
|
+
const res = await fetch(url.toString());
|
|
104
|
+
if (!res.ok) {
|
|
105
|
+
const body = await res.text().catch(() => '(unreadable)');
|
|
106
|
+
throw new Error(`Failed to fetch QR code: ${res.status} ${res.statusText} ${body}`);
|
|
107
|
+
}
|
|
108
|
+
return (await res.json()) as QRCodeResponse;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export async function pollQRStatus(qrcode: string, baseUrl?: string): Promise<QRStatusResponse> {
|
|
112
|
+
const base = ensureTrailingSlash(baseUrl ?? BASE_URL);
|
|
113
|
+
const url = new URL(`ilink/bot/get_qrcode_status?qrcode=${encodeURIComponent(qrcode)}`, base);
|
|
114
|
+
|
|
115
|
+
const controller = new AbortController();
|
|
116
|
+
const timer = setTimeout(() => controller.abort(), QR_LONG_POLL_TIMEOUT_MS);
|
|
117
|
+
try {
|
|
118
|
+
const res = await fetch(url.toString(), {
|
|
119
|
+
headers: { 'iLink-App-ClientVersion': '1' },
|
|
120
|
+
signal: controller.signal,
|
|
121
|
+
});
|
|
122
|
+
clearTimeout(timer);
|
|
123
|
+
if (!res.ok) {
|
|
124
|
+
const body = await res.text().catch(() => '');
|
|
125
|
+
throw new Error(`QR status poll failed: ${res.status} ${body}`);
|
|
126
|
+
}
|
|
127
|
+
return (await res.json()) as QRStatusResponse;
|
|
128
|
+
} catch (err) {
|
|
129
|
+
clearTimeout(timer);
|
|
130
|
+
if (err instanceof Error && err.name === 'AbortError') {
|
|
131
|
+
return { status: 'wait' };
|
|
132
|
+
}
|
|
133
|
+
throw err;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// ---------------------------------------------------------------------------
|
|
138
|
+
// Message APIs
|
|
139
|
+
// ---------------------------------------------------------------------------
|
|
140
|
+
|
|
141
|
+
export async function getUpdates(
|
|
142
|
+
token: string,
|
|
143
|
+
buf: string,
|
|
144
|
+
baseUrl?: string,
|
|
145
|
+
timeoutMs?: number,
|
|
146
|
+
): Promise<GetUpdatesResp> {
|
|
147
|
+
const timeout = timeoutMs ?? DEFAULT_LONG_POLL_TIMEOUT_MS;
|
|
148
|
+
try {
|
|
149
|
+
const rawText = await apiFetch({
|
|
150
|
+
baseUrl,
|
|
151
|
+
endpoint: 'ilink/bot/getupdates',
|
|
152
|
+
body: JSON.stringify({
|
|
153
|
+
get_updates_buf: buf ?? '',
|
|
154
|
+
base_info: buildBaseInfo(),
|
|
155
|
+
}),
|
|
156
|
+
token,
|
|
157
|
+
timeoutMs: timeout,
|
|
158
|
+
label: 'getUpdates',
|
|
159
|
+
});
|
|
160
|
+
return JSON.parse(rawText) as GetUpdatesResp;
|
|
161
|
+
} catch (err) {
|
|
162
|
+
if (err instanceof Error && err.name === 'AbortError') {
|
|
163
|
+
return { ret: 0, msgs: [], get_updates_buf: buf };
|
|
164
|
+
}
|
|
165
|
+
throw err;
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
export async function sendMessage(
|
|
170
|
+
token: string,
|
|
171
|
+
to: string,
|
|
172
|
+
text: string,
|
|
173
|
+
contextToken: string,
|
|
174
|
+
baseUrl?: string,
|
|
175
|
+
): Promise<void> {
|
|
176
|
+
const body: SendMessageReq = {
|
|
177
|
+
msg: {
|
|
178
|
+
from_user_id: '',
|
|
179
|
+
to_user_id: to,
|
|
180
|
+
client_id: `wechat-cc-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
|
|
181
|
+
message_type: 2, // BOT
|
|
182
|
+
message_state: 2, // FINISH
|
|
183
|
+
item_list: [{ type: 1, text_item: { text } }],
|
|
184
|
+
context_token: contextToken,
|
|
185
|
+
},
|
|
186
|
+
};
|
|
187
|
+
await apiFetch({
|
|
188
|
+
baseUrl,
|
|
189
|
+
endpoint: 'ilink/bot/sendmessage',
|
|
190
|
+
body: JSON.stringify({ ...body, base_info: buildBaseInfo() }),
|
|
191
|
+
token,
|
|
192
|
+
timeoutMs: DEFAULT_API_TIMEOUT_MS,
|
|
193
|
+
label: 'sendMessage',
|
|
194
|
+
});
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
export async function sendTyping(
|
|
198
|
+
token: string,
|
|
199
|
+
userId: string,
|
|
200
|
+
ticket: string,
|
|
201
|
+
status = 1,
|
|
202
|
+
baseUrl?: string,
|
|
203
|
+
): Promise<void> {
|
|
204
|
+
const body: SendTypingReq = {
|
|
205
|
+
ilink_user_id: userId,
|
|
206
|
+
typing_ticket: ticket,
|
|
207
|
+
status,
|
|
208
|
+
};
|
|
209
|
+
await apiFetch({
|
|
210
|
+
baseUrl,
|
|
211
|
+
endpoint: 'ilink/bot/sendtyping',
|
|
212
|
+
body: JSON.stringify({ ...body, base_info: buildBaseInfo() }),
|
|
213
|
+
token,
|
|
214
|
+
timeoutMs: DEFAULT_CONFIG_TIMEOUT_MS,
|
|
215
|
+
label: 'sendTyping',
|
|
216
|
+
});
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
export async function getConfig(
|
|
220
|
+
token: string,
|
|
221
|
+
userId: string,
|
|
222
|
+
contextToken?: string,
|
|
223
|
+
baseUrl?: string,
|
|
224
|
+
): Promise<GetConfigResp> {
|
|
225
|
+
const rawText = await apiFetch({
|
|
226
|
+
baseUrl,
|
|
227
|
+
endpoint: 'ilink/bot/getconfig',
|
|
228
|
+
body: JSON.stringify({
|
|
229
|
+
ilink_user_id: userId,
|
|
230
|
+
context_token: contextToken,
|
|
231
|
+
base_info: buildBaseInfo(),
|
|
232
|
+
}),
|
|
233
|
+
token,
|
|
234
|
+
timeoutMs: DEFAULT_CONFIG_TIMEOUT_MS,
|
|
235
|
+
label: 'getConfig',
|
|
236
|
+
});
|
|
237
|
+
return JSON.parse(rawText) as GetConfigResp;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// ---------------------------------------------------------------------------
|
|
241
|
+
// CDN Upload & Media Send
|
|
242
|
+
// ---------------------------------------------------------------------------
|
|
243
|
+
|
|
244
|
+
/** AES-128-ECB encrypt (PKCS7 padding). */
|
|
245
|
+
export function encryptAesEcb(plaintext: Buffer, key: Buffer): Buffer {
|
|
246
|
+
const cipher = crypto.createCipheriv('aes-128-ecb', key, null);
|
|
247
|
+
return Buffer.concat([cipher.update(plaintext), cipher.final()]);
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/** Compute AES-ECB ciphertext size (PKCS7 padding). */
|
|
251
|
+
export function aesEcbPaddedSize(plaintextSize: number): number {
|
|
252
|
+
return Math.ceil((plaintextSize + 1) / 16) * 16;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/** Determine media type from file extension: 1=IMAGE, 2=VIDEO, 3=FILE. */
|
|
256
|
+
function detectMediaType(filePath: string): number {
|
|
257
|
+
const ext = path.extname(filePath).toLowerCase();
|
|
258
|
+
if (['.jpg', '.jpeg', '.png', '.gif', '.bmp', '.webp'].includes(ext)) return 1;
|
|
259
|
+
if (['.mp4', '.mov', '.avi', '.mkv'].includes(ext)) return 2;
|
|
260
|
+
return 3;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
interface GetUploadUrlResp {
|
|
264
|
+
upload_param?: string;
|
|
265
|
+
filekey?: string;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* Upload a local file to WeChat CDN and send it as a media message.
|
|
270
|
+
*/
|
|
271
|
+
export async function uploadAndSendMedia(params: {
|
|
272
|
+
token: string;
|
|
273
|
+
toUser: string;
|
|
274
|
+
contextToken: string;
|
|
275
|
+
filePath: string;
|
|
276
|
+
baseUrl?: string;
|
|
277
|
+
cdnBaseUrl?: string;
|
|
278
|
+
}): Promise<void> {
|
|
279
|
+
const { token, toUser, contextToken, filePath, baseUrl, cdnBaseUrl } = params;
|
|
280
|
+
|
|
281
|
+
// 1. Read file, compute rawsize + MD5
|
|
282
|
+
const fileData = fs.readFileSync(filePath);
|
|
283
|
+
const rawsize = fileData.length;
|
|
284
|
+
const rawfilemd5 = crypto.createHash('md5').update(fileData).digest('hex');
|
|
285
|
+
|
|
286
|
+
// 2. Generate random AES key (16 bytes)
|
|
287
|
+
const aeskey = crypto.randomBytes(16);
|
|
288
|
+
|
|
289
|
+
// 3. Detect media type
|
|
290
|
+
const mediaType = detectMediaType(filePath);
|
|
291
|
+
|
|
292
|
+
// 4. Encrypt
|
|
293
|
+
const ciphertext = encryptAesEcb(fileData, aeskey);
|
|
294
|
+
const ciphertextSize = ciphertext.length;
|
|
295
|
+
|
|
296
|
+
// 5. Get upload URL
|
|
297
|
+
const filekey = `wcc-${Date.now()}-${Math.random().toString(36).slice(2, 8)}${path.extname(filePath)}`;
|
|
298
|
+
|
|
299
|
+
const uploadUrlBody = JSON.stringify({
|
|
300
|
+
filekey,
|
|
301
|
+
media_type: mediaType,
|
|
302
|
+
to_user_id: toUser,
|
|
303
|
+
rawsize,
|
|
304
|
+
rawfilemd5,
|
|
305
|
+
filesize: ciphertextSize,
|
|
306
|
+
no_need_thumb: true,
|
|
307
|
+
aeskey: aeskey.toString('hex'),
|
|
308
|
+
base_info: buildBaseInfo(),
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
const uploadUrlRaw = await apiFetch({
|
|
312
|
+
baseUrl,
|
|
313
|
+
endpoint: 'ilink/bot/getuploadurl',
|
|
314
|
+
body: uploadUrlBody,
|
|
315
|
+
token,
|
|
316
|
+
timeoutMs: DEFAULT_API_TIMEOUT_MS,
|
|
317
|
+
label: 'getUploadUrl',
|
|
318
|
+
});
|
|
319
|
+
const uploadUrlResp = JSON.parse(uploadUrlRaw) as GetUploadUrlResp;
|
|
320
|
+
const uploadParam = uploadUrlResp.upload_param;
|
|
321
|
+
const serverFilekey = uploadUrlResp.filekey || filekey;
|
|
322
|
+
if (!uploadParam) {
|
|
323
|
+
throw new Error('getUploadUrl did not return upload_param');
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
// 6. Upload to CDN
|
|
327
|
+
const cdn = cdnBaseUrl ?? CDN_BASE_URL;
|
|
328
|
+
const uploadUrl = `${cdn}/upload?encrypted_query_param=${encodeURIComponent(uploadParam)}&filekey=${encodeURIComponent(serverFilekey)}`;
|
|
329
|
+
|
|
330
|
+
const headers = buildHeaders(token);
|
|
331
|
+
headers['Content-Type'] = 'application/octet-stream';
|
|
332
|
+
headers['Content-Length'] = String(ciphertextSize);
|
|
333
|
+
|
|
334
|
+
const controller = new AbortController();
|
|
335
|
+
const timer = setTimeout(() => controller.abort(), 60_000);
|
|
336
|
+
try {
|
|
337
|
+
const res = await fetch(uploadUrl, {
|
|
338
|
+
method: 'POST',
|
|
339
|
+
headers,
|
|
340
|
+
body: new Uint8Array(ciphertext),
|
|
341
|
+
signal: controller.signal,
|
|
342
|
+
});
|
|
343
|
+
clearTimeout(timer);
|
|
344
|
+
|
|
345
|
+
if (!res.ok) {
|
|
346
|
+
const body = await res.text().catch(() => '');
|
|
347
|
+
throw new Error(`CDN upload failed: ${res.status} ${body}`);
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
const downloadParam = res.headers.get('x-encrypted-param');
|
|
351
|
+
if (!downloadParam) {
|
|
352
|
+
throw new Error('CDN upload did not return x-encrypted-param header');
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
// 7. Build media item and send message
|
|
356
|
+
const aesKeyBase64 = Buffer.from(aeskey.toString('hex')).toString('base64');
|
|
357
|
+
const mediaInfo = {
|
|
358
|
+
encrypt_query_param: downloadParam,
|
|
359
|
+
aes_key: aesKeyBase64,
|
|
360
|
+
encrypt_type: 1,
|
|
361
|
+
};
|
|
362
|
+
|
|
363
|
+
let mediaItem: Record<string, unknown>;
|
|
364
|
+
if (mediaType === 1) {
|
|
365
|
+
mediaItem = { type: 2, image_item: { media: mediaInfo, mid_size: ciphertextSize } };
|
|
366
|
+
} else if (mediaType === 2) {
|
|
367
|
+
mediaItem = { type: 5, video_item: { media: mediaInfo, video_size: ciphertextSize } };
|
|
368
|
+
} else {
|
|
369
|
+
mediaItem = {
|
|
370
|
+
type: 4,
|
|
371
|
+
file_item: {
|
|
372
|
+
media: mediaInfo,
|
|
373
|
+
file_name: path.basename(filePath),
|
|
374
|
+
len: String(rawsize),
|
|
375
|
+
md5: rawfilemd5,
|
|
376
|
+
},
|
|
377
|
+
};
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
const msgBody = {
|
|
381
|
+
msg: {
|
|
382
|
+
from_user_id: '',
|
|
383
|
+
to_user_id: toUser,
|
|
384
|
+
client_id: `wcc-${Date.now()}`,
|
|
385
|
+
message_type: 2,
|
|
386
|
+
message_state: 2,
|
|
387
|
+
item_list: [mediaItem],
|
|
388
|
+
context_token: contextToken,
|
|
389
|
+
},
|
|
390
|
+
base_info: buildBaseInfo(),
|
|
391
|
+
};
|
|
392
|
+
|
|
393
|
+
await apiFetch({
|
|
394
|
+
baseUrl,
|
|
395
|
+
endpoint: 'ilink/bot/sendmessage',
|
|
396
|
+
body: JSON.stringify(msgBody),
|
|
397
|
+
token,
|
|
398
|
+
timeoutMs: DEFAULT_API_TIMEOUT_MS,
|
|
399
|
+
label: 'sendMediaMessage',
|
|
400
|
+
});
|
|
401
|
+
} catch (err) {
|
|
402
|
+
clearTimeout(timer);
|
|
403
|
+
throw err;
|
|
404
|
+
}
|
|
405
|
+
}
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2022",
|
|
4
|
+
"module": "NodeNext",
|
|
5
|
+
"moduleResolution": "NodeNext",
|
|
6
|
+
"outDir": "dist",
|
|
7
|
+
"rootDir": "src",
|
|
8
|
+
"strict": true,
|
|
9
|
+
"esModuleInterop": true,
|
|
10
|
+
"skipLibCheck": true,
|
|
11
|
+
"forceConsistentCasingInFileNames": true,
|
|
12
|
+
"declaration": true,
|
|
13
|
+
"sourceMap": true
|
|
14
|
+
},
|
|
15
|
+
"include": ["src"],
|
|
16
|
+
"exclude": ["node_modules", "dist"]
|
|
17
|
+
}
|