@aluria/wechat-ai-api 1.0.0 → 1.0.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/dist/index.js CHANGED
@@ -1,7 +1,1075 @@
1
- import{EventEmitter as re}from"events";import{createCipheriv as q,createDecipheriv as H,createHash as V,randomBytes as O}from"crypto";var $="https://ilinkai.weixin.qq.com",G="https://cdn.weixin.qq.com";var b="aes-128-ecb";var g={AUTH_TYPE:"AuthorizationType",UIN:"X-WECHAT-UIN",APP_ID:"iLink-App-Id",CLIENT_VERSION:"iLink-App-ClientVersion",CONTENT_TYPE:"Content-Type",AUTHORIZATION:"Authorization",ERROR_MSG:"x-error-message",ENCRYPTED_PARAM:"x-encrypted-param"},w={AUTH_TYPE_VALUE:"ilink_bot_token",CONTENT_TYPE_JSON:"application/json",DEFAULT_TIMEOUT_MS:3e4},_={appId:"bot",version:"2.1.1",baseUrl:$,botType:"3",autoDownloadMedia:!0},M={maxRetry:3,cdnBaseUrl:G,apiTimeoutMs:15e3,backoffBaseMs:1e3},R={maxRetries:3,pollTimeoutMs:35e3,onQrCode:a=>{console.log(`
2
- ==========================================`),console.log("\u8BF7\u5728\u6D4F\u89C8\u5668\u4E2D\u6253\u5F00\u4EE5\u4E0B\u94FE\u63A5\uFF0C\u5E76\u4F7F\u7528\u5FAE\u4FE1\u626B\u7801\uFF1A"),console.log(a.qrcodeUrl),console.log(`==========================================
3
- `)},onStatusChange:a=>{switch(a.status){case"wait":console.log("\u7B49\u5F85\u767B\u5F55...");break;case"scaned":console.log(`
4
- \u{1F440} \u5DF2\u626B\u7801\uFF0C\u8BF7\u5728\u624B\u673A\u5FAE\u4FE1\u4E0A\u70B9\u51FB\u786E\u8BA4\u767B\u5F55...`);break;case"confirmed":console.log(`
5
- \u5DF2\u94FE\u63A5, \u4F46\u9700\u624B\u52A8\u5728\u5FAE\u4FE1\u53D1\u9001\u7B2C\u4E00\u6761\u6D88\u606F\u540E, bot \u624D\u80FD\u6B63\u5E38\u56DE\u590D`);break;case"scaned_but_redirect":console.log(`
6
- [\u7CFB\u7EDF] ${a.message}`);break}}},f={contextToken:void 0,userId:void 0,caption:void 0};var d=class{static md5(e){return V("md5").update(e).digest("hex")}static generateRandomKey(e=16){return O(e)}static getPaddedSize(e){return Math.ceil((e+1)/16)*16}static aesEcbEncrypt(e,t){if(t.length!==16)throw new Error(`[CryptoUtils] AES-128 \u5BC6\u94A5\u957F\u5EA6\u5FC5\u987B\u4E3A ${16} \u5B57\u8282\uFF0C\u5F53\u524D\u4E3A ${t.length} \u5B57\u8282`);let r=q(b,t,null);return Buffer.concat([r.update(e),r.final()])}static aesEcbDecrypt(e,t){if(t.length!==16)throw new Error(`[CryptoUtils] AES-128 \u5BC6\u94A5\u957F\u5EA6\u5FC5\u987B\u4E3A ${16} \u5B57\u8282\uFF0C\u5F53\u524D\u4E3A ${t.length} \u5B57\u8282`);let r=H(b,t,null);return Buffer.concat([r.update(e),r.final()])}static generateClientId(){let e=O(4).toString("hex");return`wechat-bot:${Date.now()}-${e}`}static randomWechatUin(){let e=O(4).readUInt32BE(0);return Buffer.from(String(e),"utf-8").toString("base64")}};var S=(s=>(s.WAIT="wait",s.SCANNED="scaned",s.REDIRECT="scaned_but_redirect",s.CONFIRMED="confirmed",s.EXPIRED="expired",s))(S||{});function m(a,...e){let t={...a};for(let r of e)for(let n in r)r[n]!==void 0&&(t[n]=r[n]);return t}function L(a){switch(a){case 1:return"text";case 2:return"image";case 5:return"video";case 4:return"file";case 3:return"voice";default:return"unknown"}}function k(a){let e=a.split(".").map(s=>parseInt(s,10)),t=e[0]||0,r=e[1]||0,n=e[2]||0;return(t&255)<<16|(r&255)<<8|n&255}function B(a){if("file_item"in a)return a.file_item;if("image_item"in a)return a.image_item;if("voice_item"in a)return a.voice_item;if("video_item"in a)return a.video_item}function N(a){switch(a){case 2:case 3:case 4:case 5:return!0;case 0:case 1:default:return!1}}var h=class{config;clientVersionInt;constructor(e){this.config=m(_,e),this.clientVersionInt=k(e.version)}setToken(e){this.config.token=e}setBaseUrl(e){this.config.baseUrl=e}setUserId(e){this.config.userId=e}getBaseUrl(){return this.config.baseUrl}getBotType(){return this.config.botType}getUserId(){return this.config.userId}async request(e,t){let r=this.config.baseUrl.endsWith("/")?this.config.baseUrl:`${this.config.baseUrl}/`,n=new URL(e,r),s;if(t.method==="POST"&&t.body){let u={...t.body};u.base_info={channel_version:this.config.version},s=JSON.stringify(u)}else t.method==="POST"&&!t.body&&(s=JSON.stringify({base_info:{channel_version:this.config.version}}));let i=this.buildHeaders(s),o=new AbortController,c=setTimeout(()=>o.abort(),t.timeoutMs);try{let u=await fetch(n.toString(),{method:t.method,headers:i,body:s,signal:o.signal});clearTimeout(c);let l=await u.text();if(!u.ok)throw new Error(`WeChat API Error [${t.method} ${e}] HTTP ${u.status}: ${l}`);return l?JSON.parse(l):{}}catch(u){throw clearTimeout(c),u}}buildHeaders(e){let t={[g.CONTENT_TYPE]:w.CONTENT_TYPE_JSON,[g.AUTH_TYPE]:w.AUTH_TYPE_VALUE,[g.UIN]:d.randomWechatUin(),[g.APP_ID]:this.config.appId,[g.CLIENT_VERSION]:String(this.clientVersionInt)};return e&&(t["Content-Length"]=String(Buffer.byteLength(e,"utf-8"))),this.config.token?.trim()&&(t.Authorization=`Bearer ${this.config.token.trim()}`),t}};var C=class{core;constructor(e){this.core=e}async getQrCode(){this.core.setBaseUrl(this.core.getBaseUrl());let e=await this.core.request(`ilink/bot/get_bot_qrcode?bot_type=${this.core.getBotType()}`,{method:"GET",timeoutMs:1e4});return{qrcodeId:e.qrcode,qrcodeUrl:e.qrcode_img_content}}async pollLoginStatus(e,t=35e3){try{let r=await this.core.request(`ilink/bot/get_qrcode_status?qrcode=${encodeURIComponent(e)}`,{method:"GET",timeoutMs:t});return{status:r.status,data:r}}catch(r){if(r.name==="AbortError")return{status:"wait"};throw r}}async login(e){let t=e.maxRetries??3,r=0;for(;r<=t;){let n=await this.getQrCode();e.onQrCode(n);let s=Date.now()+3e5;for(;Date.now()<s;){let{status:i,data:o}=await this.pollLoginStatus(n.qrcodeId,e.pollTimeoutMs);switch(i){case"wait":e.onStatusChange?.({status:"wait",message:"\u7B49\u5F85\u626B\u7801..."});break;case"scaned":e.onStatusChange?.({status:"scaned",message:"\u5DF2\u626B\u7801\uFF0C\u8BF7\u5728\u624B\u673A\u7AEF\u786E\u8BA4..."});break;case"scaned_but_redirect":if(o.redirect_host){let c=`https://${o.redirect_host}`;this.core.setBaseUrl(c),e.onStatusChange?.({status:"scaned_but_redirect",message:`\u670D\u52A1\u5668\u8981\u6C42\u91CD\u5B9A\u5411\u81F3: ${c}`,redirect_host:o.redirect_host})}break;case"confirmed":return e.onStatusChange?.({status:"confirmed",message:"\u767B\u5F55\u6210\u529F\uFF01",bot_token:o.bot_token,baseurl:o.baseurl,ilink_bot_id:o.ilink_bot_id,ilink_user_id:o.ilink_user_id}),this.core.setToken(o.bot_token),this.core.setUserId(o.ilink_user_id),o.baseurl&&this.core.setBaseUrl(o.baseurl),{token:o.bot_token,baseUrl:o.baseurl||this.core.getBaseUrl(),accountId:o.ilink_bot_id,userId:o.ilink_user_id};case"expired":e.onStatusChange?.({status:"expired",message:"\u4E8C\u7EF4\u7801\u5DF2\u8FC7\u671F\uFF0C\u6B63\u5728\u91CD\u65B0\u83B7\u53D6..."});break;default:await new Promise(c=>setTimeout(c,1e3))}if(i==="expired")break;await new Promise(c=>setTimeout(c,1e3))}r++}throw new Error(`\u767B\u5F55\u5931\u8D25\uFF1A\u4E8C\u7EF4\u7801\u5DF2\u8FC7\u671F\u5E76\u8D85\u8FC7\u6700\u5927\u91CD\u8BD5\u6B21\u6570 (${t}\u6B21)\u3002`)}};import{readFile as K}from"fs/promises";import{basename as j}from"path";var I=class{core;cdn;constructor(e,t){this.core=e,this.cdn=t}async sendText(e,t=f){let r={type:1,text_item:{text:e}},n=m(f,{userId:this.core.getUserId()},t);return this._sendRawItem(r,n.userId,n.contextToken)}async sendImage(e,t=f){let r=m(f,{userId:this.core.getUserId()},t);return this._sendMediaWorkflow(e,1,r)}async sendVideo(e,t=f){let r=m(f,{userId:this.core.getUserId()},t);return this._sendMediaWorkflow(e,2,r)}async sendFile(e,t=f){let r=m(f,{userId:this.core.getUserId()},t);return this._sendMediaWorkflow(e,3,r)}async _sendMediaWorkflow(e,t,r){let n=await K(e),s=j(e),i=await this.cdn.uploadBuffer(n,r.userId,t);r.caption&&await this.sendText(r.caption,{contextToken:r.contextToken});let o={encrypt_query_param:i.encryptedQueryParam,aes_key:Buffer.from(i.aeskeyHex).toString("base64"),encrypt_type:1},c={};switch(t){case 1:c={type:2,image_item:{media:o,mid_size:i.fileSizeCipher}};break;case 2:c={type:5,video_item:{media:o,video_size:i.fileSizeCipher}};break;case 3:c={type:4,file_item:{media:o,file_name:s,len:String(i.fileSizePlain)}};break;default:throw new Error(`[MessageManager] \u4E0D\u652F\u6301\u7684\u5A92\u4F53\u7C7B\u578B: ${t}`)}return this._sendRawItem(c,r.userId,r.contextToken)}async _sendRawItem(e,t,r){let n=d.generateClientId(),s={msg:{from_user_id:"",to_user_id:t,client_id:n,message_type:2,message_state:2,context_token:r,item_list:[e]}},i=await this.core.request("ilink/bot/sendmessage",{method:"POST",body:s,timeoutMs:15e3});return{clientId:n,response:i}}};var E=class{core;config;constructor(e,t=M){this.core=e,this.config=m(M,t)}async uploadBuffer(e,t,r){let n=e.length,s=d.md5(e),i=d.getPaddedSize(n),o=d.generateRandomKey(16).toString("hex"),c=d.generateRandomKey(16),u=c.toString("hex"),l=await this.core.request("ilink/bot/getuploadurl",{method:"POST",body:{filekey:o,media_type:r,to_user_id:t,rawsize:n,rawfilemd5:s,filesize:i,no_need_thumb:!0,aeskey:u},timeoutMs:this.config.apiTimeoutMs}),y="";if(l.upload_full_url?.trim())y=l.upload_full_url.trim();else if(l.upload_param)y=`${this.config.cdnBaseUrl}/upload?encrypted_query_param=${encodeURIComponent(l.upload_param)}&filekey=${encodeURIComponent(o)}`;else throw new Error("[CdnManager] \u83B7\u53D6\u4E0A\u4F20\u5730\u5740\u5931\u8D25\uFF0CAPI \u54CD\u5E94\u7F3A\u5C11 full_url \u6216 upload_param");let F=d.aesEcbEncrypt(e,c),v=await this.postToCdn(F,y);return{filekey:o,aeskeyHex:u,aeskeyBuffer:c,fileSizePlain:n,fileSizeCipher:i,encryptedQueryParam:v}}async postToCdn(e,t){let r=this.config.maxRetry,n;for(let s=1;s<=r;s++)try{let i=await fetch(t,{method:"POST",headers:{"Content-Type":"application/octet-stream"},body:new Uint8Array(e)});if(i.status>=400&&i.status<500){let c=i.headers.get(g.ERROR_MSG)??await i.text();throw new Error(`[CdnClientError] HTTP ${i.status}: ${c}`)}if(i.status!==200){let c=i.headers.get(g.ERROR_MSG)??`HTTP ${i.status}`;throw new Error(`[CdnServerError]: ${c}`)}let o=i.headers.get(g.ENCRYPTED_PARAM);if(!o)throw new Error(`[CdnManager] \u4E0A\u4F20\u6210\u529F\uFF0C\u4F46\u54CD\u5E94\u5934\u4E2D\u7F3A\u5C11 ${g.ENCRYPTED_PARAM}`);return o}catch(i){if(n=i,i.message&&i.message.includes("[CdnClientError]"))throw i;s<r&&await new Promise(o=>setTimeout(o,this.config.backoffBaseMs*s))}throw new Error(`[CdnManager] CDN \u4E0A\u4F20\u5931\u8D25\uFF0C\u5DF2\u91CD\u8BD5 ${r} \u6B21\u3002\u6700\u540E\u9519\u8BEF: ${n?.message}`)}async downloadBuffer(e){let t="";if(e.fullUrl)t=e.fullUrl;else if(e.encryptedQueryParam)t=`${this.config.cdnBaseUrl}/download?encrypted_query_param=${encodeURIComponent(e.encryptedQueryParam)}`;else throw new Error("[CdnManager] \u4E0B\u8F7D\u5931\u8D25\uFF1A\u7F3A\u5C11 fullUrl \u548C encryptedQueryParam");let r=await this.fetchCdnBytes(t);if(e.isPlain||!e.aesKeyBase64)return r;let n=this.parseWechatAesKey(e.aesKeyBase64);return d.aesEcbDecrypt(r,n)}parseWechatAesKey(e){let t=Buffer.from(e,"base64");if(t.length===16)return t;if(t.length===32&&/^[0-9a-fA-F]{32}$/i.test(t.toString("ascii")))return Buffer.from(t.toString("ascii"),"hex");throw new Error(`[CdnManager] \u65E0\u6CD5\u89E3\u6790\u7684 AES \u5BC6\u94A5\u683C\u5F0F (Base64="${e}")`)}async fetchCdnBytes(e){let t=await fetch(e);if(!t.ok){let r=await t.text().catch(()=>"(unreadable)");throw new Error(`[CdnManager] CDN \u4E0B\u8F7D\u7F51\u7EDC\u9519\u8BEF HTTP ${t.status}: ${r}`)}return Buffer.from(await t.arrayBuffer())}};function Z(a,e){let t=a.byteLength,r=44+t,n=Buffer.allocUnsafe(r),s=0;return n.write("RIFF",s),s+=4,n.writeUInt32LE(r-8,s),s+=4,n.write("WAVE",s),s+=4,n.write("fmt ",s),s+=4,n.writeUInt32LE(16,s),s+=4,n.writeUInt16LE(1,s),s+=2,n.writeUInt16LE(1,s),s+=2,n.writeUInt32LE(e,s),s+=4,n.writeUInt32LE(e*2,s),s+=4,n.writeUInt16LE(2,s),s+=2,n.writeUInt16LE(16,s),s+=2,n.write("data",s),s+=4,n.writeUInt32LE(t,s),s+=4,Buffer.from(a.buffer,a.byteOffset,a.byteLength).copy(n,s),n}async function W(a,e=24e3){try{let{decode:t}=await import("silk-wasm"),r=await t(a,e);return Z(r.data,e)}catch{return null}}import{writeFile as ee}from"fs/promises";var T=class{cdn;config;constructor(e,t){this.cdn=e,this.config=t}async parse(e){if(!e.item_list||e.item_list.length===0)return[];let t=[],r={messageId:String(e.message_id),seq:e.seq,fromUserId:e.from_user_id,toUserId:e.to_user_id,timestamp:e.create_time_ms,contextToken:e.context_token,msgType:1,msgTypeStr:"unknown",index:0,raw:e};for(let n=0;n<e.item_list.length;n++){let s=e.item_list[n],i=m(r,{index:n,msgType:s.type,msgTypeStr:L(s.type)});if(s.type===1&&(i.text=s.text_item.text),!N(s.type)){t.push(i);continue}let o=null,c=this._extractDownloadTicket(s);if(i.fileName=c?.originalFileName,i.mediaTicket=c,i.getBuffer=async()=>o||(c?(o=await this.cdn.downloadBuffer(c),o):null),i.saveToFile=async u=>{let l=await i.getBuffer();if(!l)throw new Error("\u65E0\u5A92\u4F53\u5185\u5BB9\u53EF\u4FDD\u5B58");return await ee(u,l),u},i.msgType===3){i.getVoiceBuffer=i.getBuffer;let u=null;i.getBuffer=async()=>{if(u)return u;let l=await i.getVoiceBuffer();return l?(u=await W(l),u):null}}if(this.config.autoDownloadMedia!==!1&&c)try{i.getBuffer()}catch(u){console.error(`\u81EA\u52A8\u4E0B\u8F7D\u5A92\u4F53\u5931\u8D25: ${u.message}`)}t.push(i)}return t}_extractDownloadTicket(e){let t=B(e);if(!t||!t.media)return;let r=t.media;if(!r.encrypt_query_param&&!r.full_url)return;let n=r.aes_key;e.type===2&&"aeskey"in t&&t.aeskey&&(n=Buffer.from(t.aeskey,"hex").toString("base64"));let s=!n;return{fullUrl:r.full_url,encryptedQueryParam:r.encrypt_query_param,aesKeyBase64:n,isPlain:s,originalFileName:"file_name"in t?t.file_name:void 0}}};var P=class extends re{core;auth;messages;parser;cdn;currentCredentials=null;isPolling=!1;syncBuf="";constructor(e=_){super();let t=m(_,e);this.core=new h(t),this.auth=new C(this.core),this.cdn=new E(this.core),this.messages=new I(this.core,this.cdn),this.parser=new T(this.cdn,t),t.token&&(this.currentCredentials={token:t.token,baseUrl:t.baseUrl,accountId:"",userId:""})}async login(e=R){let t=m(R,e),r=await this.auth.login(t);return this.currentCredentials=r,this.emit("login",this.currentCredentials),this.currentCredentials}exportCredentials(){return!this.currentCredentials||!this.currentCredentials.token?null:{...this.currentCredentials}}loadCredentials(e){this.currentCredentials={...e},this.core.setToken(e.token),this.core.setBaseUrl(e.baseUrl),this.core.setUserId(e.userId),console.log(`[WeChatApi] \u6210\u529F\u52A0\u8F7D\u51ED\u8BC1 (AccountID: ${e.accountId})`)}async verifyCredentials(){if(!this.currentCredentials||!this.currentCredentials.token)return!1;try{let e=await this.core.request("ilink/bot/getconfig",{method:"POST",body:{ilink_user_id:this.currentCredentials.userId},timeoutMs:1e4});return e.ret!==void 0&&e.ret!==0?(console.warn(`[WeChatApi] \u51ED\u8BC1\u5DF2\u5931\u6548 (Server returned: ${e.errcode||e.ret})`),!1):e.errcode!==void 0&&e.errcode!==0?(console.warn(`[WeChatApi] \u51ED\u8BC1\u5DF2\u5931\u6548 (Server returned errcode: ${e.errcode}): ${e.errmsg}`),!1):!0}catch(e){return console.error("[WeChatApi] \u51ED\u8BC1\u9A8C\u8BC1\u5931\u8D25\uFF0C\u7F51\u7EDC\u5F02\u5E38\u6216\u5DF2\u8FC7\u671F:",e.message),!1}}async startPolling(){if(this.isPolling){console.warn("[WeChatApi] \u8F6E\u8BE2\u5DF2\u7ECF\u5728\u8FD0\u884C\u4E2D\uFF0C\u8BF7\u52FF\u91CD\u590D\u542F\u52A8");return}if(!this.currentCredentials?.token)throw new Error("[WeChatApi] \u65E0\u6CD5\u542F\u52A8\u8F6E\u8BE2\uFF1A\u5C1A\u672A\u767B\u5F55\u6216\u672A\u52A0\u8F7D\u51ED\u8BC1");for(this.isPolling=!0,console.log("[WeChatApi] \u{1F680} \u8F6E\u8BE2\u5B88\u62A4\u8FDB\u7A0B\u5DF2\u542F\u52A8\uFF0C\u6B63\u5728\u76D1\u542C\u65B0\u6D88\u606F...");this.isPolling;)try{let e=await this.core.request("ilink/bot/getupdates",{method:"POST",body:{get_updates_buf:this.syncBuf},timeoutMs:35e3}),t=e.ret!==void 0&&e.ret!==0,r=e.errcode!==void 0&&e.errcode!==0;if(t||r){this.isPolling=!1,console.log(e),this.emit("error",new Error(`\u4F1A\u8BDD\u5DF2\u5931\u6548\u6216\u670D\u52A1\u7AEF\u62A5\u9519 (errcode: ${e.errcode})`)),console.error(`[WeChatApi] \u274C \u8F6E\u8BE2\u5F02\u5E38\u4E2D\u6B62\uFF1A${e.errmsg}`);break}if(e.get_updates_buf&&(this.syncBuf=e.get_updates_buf),e.msgs&&e.msgs.length>0)for(let n of e.msgs){let s=await this.parser.parse(n);if(!(!s||s.length===0))for(let i of s)this.emit("message",i),i.msgTypeStr!=="unknown"&&this.emit(i.msgTypeStr,i)}}catch(e){if(e.name==="AbortError"||e.message.includes("timeout"))continue;console.error(`[WeChatApi] \u26A0\uFE0F \u8F6E\u8BE2\u9047\u5230\u7F51\u7EDC\u5F02\u5E38\uFF0C3\u79D2\u540E\u91CD\u8BD5: ${e.message}`),await new Promise(t=>setTimeout(t,3e3))}console.log("[WeChatApi] \u{1F6D1} \u8F6E\u8BE2\u5B88\u62A4\u8FDB\u7A0B\u5DF2\u505C\u6B62\u3002")}stopPolling(){this.isPolling=!1}};export{S as LoginStatus,P as WeChatApi};
1
+ // src/WechatAiApi.ts
2
+ import { EventEmitter } from "events";
3
+
4
+ // src/core/CryptoUtils.ts
5
+ import {
6
+ createCipheriv,
7
+ createDecipheriv,
8
+ createHash,
9
+ randomBytes
10
+ } from "crypto";
11
+
12
+ // src/constants.ts
13
+ var WECHAT_DEFAULT_BASE_URL = "https://ilinkai.weixin.qq.com";
14
+ var WECHAT_DEFAULT_CDN_URL = "https://cdn.weixin.qq.com";
15
+ var DEFAULT_REQUEST_TIMEOUT_MS = 1e4;
16
+ var LONG_POLLING_TIMEOUT_MS = 35e3;
17
+ var POLLING_ERROR_RETRY_DELAY_MS = 3e3;
18
+ var DEFAULT_API_TIMEOUT_MS = 15e3;
19
+ var QR_CODE_EXPIRATION_MS = 5 * 60 * 1e3;
20
+ var BACKOFF_BASE_MS = 1e3;
21
+ var DEFAULT_LOGIN_MAX_RETRIES = 3;
22
+ var AES_BLOCK_SIZE = 16;
23
+ var WECHAT_AES_ALGO = "aes-128-ecb";
24
+ var WECHAT_CDN_ENCRYPT_TYPE = 1;
25
+ var WECHAT_SILK_SAMPLE_RATE = 24e3;
26
+ var WECHAT_HTTP_HEADERS = {
27
+ AUTH_TYPE: "AuthorizationType",
28
+ UIN: "X-WECHAT-UIN",
29
+ APP_ID: "iLink-App-Id",
30
+ CLIENT_VERSION: "iLink-App-ClientVersion",
31
+ CONTENT_TYPE: "Content-Type",
32
+ AUTHORIZATION: "Authorization",
33
+ ERROR_MSG: "x-error-message",
34
+ ENCRYPTED_PARAM: "x-encrypted-param"
35
+ };
36
+ var WECHAT_PROTOCOL = {
37
+ AUTH_TYPE_VALUE: "ilink_bot_token",
38
+ CONTENT_TYPE_JSON: "application/json",
39
+ DEFAULT_TIMEOUT_MS: 3e4
40
+ };
41
+ var DEFAULT_CLIENT_CONFIG = {
42
+ appId: "bot",
43
+ version: "2.1.1",
44
+ baseUrl: WECHAT_DEFAULT_BASE_URL,
45
+ botType: "3",
46
+ autoDownloadMedia: true
47
+ };
48
+ var DEFAULT_CDN_CONFIG = {
49
+ maxRetry: 3,
50
+ cdnBaseUrl: WECHAT_DEFAULT_CDN_URL,
51
+ apiTimeoutMs: DEFAULT_API_TIMEOUT_MS,
52
+ backoffBaseMs: BACKOFF_BASE_MS
53
+ };
54
+ var DEFAULT_LOGIN_OPTIONS = {
55
+ maxRetries: DEFAULT_LOGIN_MAX_RETRIES,
56
+ pollTimeoutMs: LONG_POLLING_TIMEOUT_MS,
57
+ onQrCode: (qrInfo) => {
58
+ console.log("\n==========================================");
59
+ console.log("\u8BF7\u5728\u6D4F\u89C8\u5668\u4E2D\u6253\u5F00\u4EE5\u4E0B\u94FE\u63A5\uFF0C\u5E76\u4F7F\u7528\u5FAE\u4FE1\u626B\u7801\uFF1A");
60
+ console.log(qrInfo.qrcodeUrl);
61
+ console.log("==========================================\n");
62
+ },
63
+ onStatusChange: (payload) => {
64
+ switch (payload.status) {
65
+ case "wait":
66
+ console.log("\u7B49\u5F85\u767B\u5F55...");
67
+ break;
68
+ case "scaned":
69
+ console.log("\n\u{1F440} \u5DF2\u626B\u7801\uFF0C\u8BF7\u5728\u624B\u673A\u5FAE\u4FE1\u4E0A\u70B9\u51FB\u786E\u8BA4\u767B\u5F55...");
70
+ break;
71
+ case "confirmed":
72
+ console.log(
73
+ "\n\u5DF2\u94FE\u63A5, \u4F46\u9700\u624B\u52A8\u5728\u5FAE\u4FE1\u53D1\u9001\u7B2C\u4E00\u6761\u6D88\u606F\u540E, bot \u624D\u80FD\u6B63\u5E38\u56DE\u590D"
74
+ );
75
+ break;
76
+ case "scaned_but_redirect":
77
+ console.log(`
78
+ [\u7CFB\u7EDF] ${payload.message}`);
79
+ break;
80
+ }
81
+ }
82
+ };
83
+ var DEFAULT_SEND_OPTIONS = {
84
+ contextToken: void 0,
85
+ userId: void 0,
86
+ caption: void 0
87
+ };
88
+
89
+ // src/core/CryptoUtils.ts
90
+ var CryptoUtils = class {
91
+ static md5(buffer) {
92
+ return createHash("md5").update(buffer).digest("hex");
93
+ }
94
+ static generateRandomKey(length = 16) {
95
+ return randomBytes(length);
96
+ }
97
+ static getPaddedSize(plaintextSize) {
98
+ return Math.ceil((plaintextSize + 1) / AES_BLOCK_SIZE) * AES_BLOCK_SIZE;
99
+ }
100
+ static aesEcbEncrypt(plaintext, key) {
101
+ if (key.length !== AES_BLOCK_SIZE) {
102
+ throw new Error(
103
+ `[CryptoUtils] AES-128 \u5BC6\u94A5\u957F\u5EA6\u5FC5\u987B\u4E3A ${AES_BLOCK_SIZE} \u5B57\u8282\uFF0C\u5F53\u524D\u4E3A ${key.length} \u5B57\u8282`
104
+ );
105
+ }
106
+ const cipher = createCipheriv(WECHAT_AES_ALGO, key, null);
107
+ return Buffer.concat([cipher.update(plaintext), cipher.final()]);
108
+ }
109
+ static aesEcbDecrypt(ciphertext, key) {
110
+ if (key.length !== AES_BLOCK_SIZE) {
111
+ throw new Error(
112
+ `[CryptoUtils] AES-128 \u5BC6\u94A5\u957F\u5EA6\u5FC5\u987B\u4E3A ${AES_BLOCK_SIZE} \u5B57\u8282\uFF0C\u5F53\u524D\u4E3A ${key.length} \u5B57\u8282`
113
+ );
114
+ }
115
+ const decipher = createDecipheriv(WECHAT_AES_ALGO, key, null);
116
+ return Buffer.concat([decipher.update(ciphertext), decipher.final()]);
117
+ }
118
+ /**
119
+ * 生成去重的客户端消息 ID
120
+ * 格式: prefix:timestamp-randomHex
121
+ */
122
+ static generateClientId() {
123
+ const randomHex = randomBytes(4).toString("hex");
124
+ return `wechat-bot:${Date.now()}-${randomHex}`;
125
+ }
126
+ /** 生成随机的 X-WECHAT-UIN (4字节随机数 -> uint32 -> base64) */
127
+ static randomWechatUin() {
128
+ const uint32 = randomBytes(4).readUInt32BE(0);
129
+ return Buffer.from(String(uint32), "utf-8").toString("base64");
130
+ }
131
+ };
132
+
133
+ // src/types.ts
134
+ var LoginStatus = /* @__PURE__ */ ((LoginStatus2) => {
135
+ LoginStatus2["WAIT"] = "wait";
136
+ LoginStatus2["SCANNED"] = "scaned";
137
+ LoginStatus2["REDIRECT"] = "scaned_but_redirect";
138
+ LoginStatus2["CONFIRMED"] = "confirmed";
139
+ LoginStatus2["EXPIRED"] = "expired";
140
+ return LoginStatus2;
141
+ })(LoginStatus || {});
142
+
143
+ // src/core/utils.ts
144
+ function mergeObjects(ref, ...sources) {
145
+ const target = { ...ref };
146
+ for (const source of sources) {
147
+ for (const key in source) {
148
+ if (source[key] !== void 0) {
149
+ target[key] = source[key];
150
+ }
151
+ }
152
+ }
153
+ return target;
154
+ }
155
+ function getItemType(item) {
156
+ switch (item) {
157
+ case 1 /* TEXT */:
158
+ return "text";
159
+ case 2 /* IMAGE */:
160
+ return "image";
161
+ case 5 /* VIDEO */:
162
+ return "video";
163
+ case 4 /* FILE */:
164
+ return "file";
165
+ case 3 /* VOICE */:
166
+ return "voice";
167
+ default:
168
+ return "unknown";
169
+ }
170
+ }
171
+ function buildClientVersion(version) {
172
+ const parts = version.split(".").map((p) => parseInt(p, 10));
173
+ const major = parts[0] || 0;
174
+ const minor = parts[1] || 0;
175
+ const patch = parts[2] || 0;
176
+ return (major & 255) << 16 | (minor & 255) << 8 | patch & 255;
177
+ }
178
+ function getFileImageItem(item) {
179
+ if ("file_item" in item) return item.file_item;
180
+ if ("image_item" in item) return item.image_item;
181
+ if ("voice_item" in item) return item.voice_item;
182
+ if ("video_item" in item) return item.video_item;
183
+ }
184
+ function isItemWithMedia(item) {
185
+ switch (item) {
186
+ case 2 /* IMAGE */:
187
+ case 3 /* VOICE */:
188
+ case 4 /* FILE */:
189
+ case 5 /* VIDEO */:
190
+ return true;
191
+ case 0 /* NONE */:
192
+ case 1 /* TEXT */:
193
+ default:
194
+ return false;
195
+ }
196
+ }
197
+
198
+ // src/core/WeChatCore.ts
199
+ var WeChatCore = class {
200
+ config;
201
+ clientVersionInt;
202
+ constructor(config) {
203
+ this.config = mergeObjects(DEFAULT_CLIENT_CONFIG, config);
204
+ this.clientVersionInt = buildClientVersion(config.version);
205
+ }
206
+ // ==========================================
207
+ // 状态修改器 (Mutators)
208
+ // ==========================================
209
+ /** 设置或更新登录凭证 */
210
+ setToken(token) {
211
+ this.config.token = token;
212
+ }
213
+ /** 更新基础网关 URL (用于处理 IDC 重定向) */
214
+ setBaseUrl(baseUrl) {
215
+ this.config.baseUrl = baseUrl;
216
+ }
217
+ setUserId(userId) {
218
+ this.config.userId = userId;
219
+ }
220
+ /** 获取当前的 BaseUrl (供某些需要拼接绝对路径的特殊场景使用) */
221
+ getBaseUrl() {
222
+ return this.config.baseUrl;
223
+ }
224
+ /** 获取当前的机器人类型 */
225
+ getBotType() {
226
+ return this.config.botType;
227
+ }
228
+ /** 获取当前的用户 ID */
229
+ getUserId() {
230
+ return this.config.userId;
231
+ }
232
+ // ==========================================
233
+ // 核心请求发射器
234
+ // ==========================================
235
+ /**
236
+ * 统一的 HTTP 请求方法
237
+ * @param endpoint 接口路径,例如 "ilink/bot/sendmessage"
238
+ * @param options 请求配置 (方法、请求体、超时设置)
239
+ */
240
+ async request(endpoint, options) {
241
+ const base = this.config.baseUrl.endsWith("/") ? this.config.baseUrl : `${this.config.baseUrl}/`;
242
+ const url = new URL(endpoint, base);
243
+ let finalBodyString = void 0;
244
+ if (options.method === "POST" && options.body) {
245
+ const payload = { ...options.body };
246
+ payload.base_info = { channel_version: this.config.version };
247
+ finalBodyString = JSON.stringify(payload);
248
+ } else if (options.method === "POST" && !options.body) {
249
+ finalBodyString = JSON.stringify({
250
+ base_info: { channel_version: this.config.version }
251
+ });
252
+ }
253
+ const headers = this.buildHeaders(finalBodyString);
254
+ const controller = new AbortController();
255
+ const timeoutId = setTimeout(() => controller.abort(), options.timeoutMs);
256
+ try {
257
+ const response = await fetch(url.toString(), {
258
+ method: options.method,
259
+ headers,
260
+ body: finalBodyString,
261
+ signal: controller.signal
262
+ });
263
+ clearTimeout(timeoutId);
264
+ const rawText = await response.text();
265
+ if (!response.ok) {
266
+ throw new Error(
267
+ `WeChat API Error [${options.method} ${endpoint}] HTTP ${response.status}: ${rawText}`
268
+ );
269
+ }
270
+ return rawText ? JSON.parse(rawText) : {};
271
+ } catch (err) {
272
+ clearTimeout(timeoutId);
273
+ throw err;
274
+ }
275
+ }
276
+ // ==========================================
277
+ // 私有辅助方法
278
+ // ==========================================
279
+ /** 构造标准请求头 */
280
+ buildHeaders(bodyString) {
281
+ const headers = {
282
+ [WECHAT_HTTP_HEADERS.CONTENT_TYPE]: WECHAT_PROTOCOL.CONTENT_TYPE_JSON,
283
+ [WECHAT_HTTP_HEADERS.AUTH_TYPE]: WECHAT_PROTOCOL.AUTH_TYPE_VALUE,
284
+ [WECHAT_HTTP_HEADERS.UIN]: CryptoUtils.randomWechatUin(),
285
+ [WECHAT_HTTP_HEADERS.APP_ID]: this.config.appId,
286
+ [WECHAT_HTTP_HEADERS.CLIENT_VERSION]: String(this.clientVersionInt)
287
+ };
288
+ if (bodyString) {
289
+ headers["Content-Length"] = String(
290
+ Buffer.byteLength(bodyString, "utf-8")
291
+ );
292
+ }
293
+ if (this.config.token?.trim()) {
294
+ headers["Authorization"] = `Bearer ${this.config.token.trim()}`;
295
+ }
296
+ return headers;
297
+ }
298
+ };
299
+
300
+ // src/managers/AuthManager.ts
301
+ var AuthManager = class {
302
+ core;
303
+ constructor(core) {
304
+ this.core = core;
305
+ }
306
+ // ==========================================
307
+ // 细粒度 API: 第一步 - 获取二维码
308
+ // ==========================================
309
+ async getQrCode() {
310
+ this.core.setBaseUrl(this.core.getBaseUrl());
311
+ const response = await this.core.request(
312
+ `ilink/bot/get_bot_qrcode?bot_type=${this.core.getBotType()}`,
313
+ { method: "GET", timeoutMs: DEFAULT_REQUEST_TIMEOUT_MS }
314
+ );
315
+ return {
316
+ qrcodeId: response.qrcode,
317
+ qrcodeUrl: response.qrcode_img_content
318
+ };
319
+ }
320
+ // ==========================================
321
+ // 细粒度 API: 第二步 - 单次轮询状态
322
+ // ==========================================
323
+ async pollLoginStatus(qrcodeId, timeoutMs = LONG_POLLING_TIMEOUT_MS) {
324
+ try {
325
+ const response = await this.core.request(
326
+ `ilink/bot/get_qrcode_status?qrcode=${encodeURIComponent(qrcodeId)}`,
327
+ { method: "GET", timeoutMs }
328
+ );
329
+ return { status: response.status, data: response };
330
+ } catch (err) {
331
+ if (err.name === "AbortError") {
332
+ return { status: "wait" /* WAIT */ };
333
+ }
334
+ throw err;
335
+ }
336
+ }
337
+ // ==========================================
338
+ // 粗粒度 API: 一键自动托管登录
339
+ // ==========================================
340
+ async login(options) {
341
+ const maxRetries = options.maxRetries ?? DEFAULT_LOGIN_MAX_RETRIES;
342
+ let currentRetry = 0;
343
+ while (currentRetry <= maxRetries) {
344
+ const qrInfo = await this.getQrCode();
345
+ options.onQrCode(qrInfo);
346
+ const qrDeadline = Date.now() + QR_CODE_EXPIRATION_MS;
347
+ while (Date.now() < qrDeadline) {
348
+ const { status, data } = await this.pollLoginStatus(
349
+ qrInfo.qrcodeId,
350
+ options.pollTimeoutMs
351
+ );
352
+ switch (status) {
353
+ case "wait" /* WAIT */:
354
+ options.onStatusChange?.({
355
+ status: "wait" /* WAIT */,
356
+ message: "\u7B49\u5F85\u626B\u7801..."
357
+ });
358
+ break;
359
+ case "scaned" /* SCANNED */:
360
+ options.onStatusChange?.({
361
+ status: "scaned" /* SCANNED */,
362
+ message: "\u5DF2\u626B\u7801\uFF0C\u8BF7\u5728\u624B\u673A\u7AEF\u786E\u8BA4..."
363
+ });
364
+ break;
365
+ case "scaned_but_redirect" /* REDIRECT */:
366
+ if (data.redirect_host) {
367
+ const newBaseUrl = `https://${data.redirect_host}`;
368
+ this.core.setBaseUrl(newBaseUrl);
369
+ options.onStatusChange?.({
370
+ status: "scaned_but_redirect" /* REDIRECT */,
371
+ message: `\u670D\u52A1\u5668\u8981\u6C42\u91CD\u5B9A\u5411\u81F3: ${newBaseUrl}`,
372
+ redirect_host: data.redirect_host
373
+ });
374
+ }
375
+ break;
376
+ case "confirmed" /* CONFIRMED */:
377
+ options.onStatusChange?.({
378
+ status: "confirmed" /* CONFIRMED */,
379
+ message: "\u767B\u5F55\u6210\u529F\uFF01",
380
+ bot_token: data.bot_token,
381
+ baseurl: data.baseurl,
382
+ ilink_bot_id: data.ilink_bot_id,
383
+ ilink_user_id: data.ilink_user_id
384
+ });
385
+ this.core.setToken(data.bot_token);
386
+ this.core.setUserId(data.ilink_user_id);
387
+ if (data.baseurl) {
388
+ this.core.setBaseUrl(data.baseurl);
389
+ }
390
+ return {
391
+ token: data.bot_token,
392
+ baseUrl: data.baseurl || this.core.getBaseUrl(),
393
+ accountId: data.ilink_bot_id,
394
+ userId: data.ilink_user_id
395
+ };
396
+ case "expired" /* EXPIRED */:
397
+ options.onStatusChange?.({
398
+ status: "expired" /* EXPIRED */,
399
+ message: "\u4E8C\u7EF4\u7801\u5DF2\u8FC7\u671F\uFF0C\u6B63\u5728\u91CD\u65B0\u83B7\u53D6..."
400
+ });
401
+ break;
402
+ default:
403
+ await new Promise((res) => setTimeout(res, BACKOFF_BASE_MS));
404
+ }
405
+ if (status === "expired" /* EXPIRED */) {
406
+ break;
407
+ }
408
+ await new Promise((res) => setTimeout(res, BACKOFF_BASE_MS));
409
+ }
410
+ currentRetry++;
411
+ }
412
+ throw new Error(
413
+ `\u767B\u5F55\u5931\u8D25\uFF1A\u4E8C\u7EF4\u7801\u5DF2\u8FC7\u671F\u5E76\u8D85\u8FC7\u6700\u5927\u91CD\u8BD5\u6B21\u6570 (${maxRetries}\u6B21)\u3002`
414
+ );
415
+ }
416
+ };
417
+
418
+ // src/managers/MessageManager.ts
419
+ import { readFile } from "fs/promises";
420
+ import { basename } from "path";
421
+ var MessageManager = class {
422
+ core;
423
+ cdn;
424
+ constructor(core, cdn) {
425
+ this.core = core;
426
+ this.cdn = cdn;
427
+ }
428
+ // ==========================================
429
+ // 公开 API: 发送纯文本
430
+ // ==========================================
431
+ async sendText(text, options = DEFAULT_SEND_OPTIONS) {
432
+ const textItem = {
433
+ type: 1 /* TEXT */,
434
+ text_item: { text }
435
+ };
436
+ const mergedOptions = mergeObjects(DEFAULT_SEND_OPTIONS, {
437
+ userId: this.core.getUserId()
438
+ }, options);
439
+ return this._sendRawItem(
440
+ textItem,
441
+ mergedOptions.userId,
442
+ mergedOptions.contextToken
443
+ );
444
+ }
445
+ // ==========================================
446
+ // 公开 API: 发送媒体文件
447
+ // ==========================================
448
+ async sendImage(filePath, options = DEFAULT_SEND_OPTIONS) {
449
+ const mergedOptions = mergeObjects(DEFAULT_SEND_OPTIONS, {
450
+ userId: this.core.getUserId()
451
+ }, options);
452
+ return this._sendMediaWorkflow(
453
+ filePath,
454
+ 1 /* IMAGE */,
455
+ mergedOptions
456
+ );
457
+ }
458
+ async sendVideo(filePath, options = DEFAULT_SEND_OPTIONS) {
459
+ const mergedOptions = mergeObjects(DEFAULT_SEND_OPTIONS, {
460
+ userId: this.core.getUserId()
461
+ }, options);
462
+ return this._sendMediaWorkflow(
463
+ filePath,
464
+ 2 /* VIDEO */,
465
+ mergedOptions
466
+ );
467
+ }
468
+ /** - 仅允许发送文件, 发送**图片或视频**会导致发送失败!
469
+ * - 发送图片和视频应使用 `sendImage` 或 `sendVideo` */
470
+ async sendFile(filePath, options = DEFAULT_SEND_OPTIONS) {
471
+ const mergedOptions = mergeObjects(DEFAULT_SEND_OPTIONS, {
472
+ userId: this.core.getUserId()
473
+ }, options);
474
+ return this._sendMediaWorkflow(
475
+ filePath,
476
+ 3 /* FILE */,
477
+ mergedOptions
478
+ );
479
+ }
480
+ // ==========================================
481
+ // 私有核心流水线
482
+ // ==========================================
483
+ /**
484
+ * 统一的媒体发送工作流:读取本地文件 -> 上传 CDN -> (可选发送 Caption) -> 发送媒体消息
485
+ */
486
+ async _sendMediaWorkflow(filePath, mediaType, options) {
487
+ const buffer = await readFile(filePath);
488
+ const fileName = basename(filePath);
489
+ const ticket = await this.cdn.uploadBuffer(
490
+ buffer,
491
+ options.userId,
492
+ mediaType
493
+ );
494
+ if (options.caption) {
495
+ await this.sendText(options.caption, {
496
+ contextToken: options.contextToken
497
+ });
498
+ }
499
+ const mediaObj = {
500
+ encrypt_query_param: ticket.encryptedQueryParam,
501
+ aes_key: Buffer.from(ticket.aeskeyHex).toString("base64"),
502
+ // JSON 中要求 base64 格式
503
+ encrypt_type: WECHAT_CDN_ENCRYPT_TYPE
504
+ };
505
+ let messageItem = {};
506
+ switch (mediaType) {
507
+ case 1 /* IMAGE */:
508
+ messageItem = {
509
+ type: 2 /* IMAGE */,
510
+ image_item: { media: mediaObj, mid_size: ticket.fileSizeCipher }
511
+ };
512
+ break;
513
+ case 2 /* VIDEO */:
514
+ messageItem = {
515
+ type: 5 /* VIDEO */,
516
+ video_item: { media: mediaObj, video_size: ticket.fileSizeCipher }
517
+ };
518
+ break;
519
+ case 3 /* FILE */:
520
+ messageItem = {
521
+ type: 4 /* FILE */,
522
+ file_item: {
523
+ media: mediaObj,
524
+ file_name: fileName,
525
+ len: String(ticket.fileSizePlain)
526
+ }
527
+ // 注意 len 是明文大小的字符串
528
+ };
529
+ break;
530
+ default:
531
+ throw new Error(`[MessageManager] \u4E0D\u652F\u6301\u7684\u5A92\u4F53\u7C7B\u578B: ${mediaType}`);
532
+ }
533
+ return this._sendRawItem(
534
+ messageItem,
535
+ options.userId,
536
+ options.contextToken
537
+ );
538
+ }
539
+ /**
540
+ * 最底层的发包函数,将组装好的 Item 包装成完整的微信请求体并 POST
541
+ */
542
+ async _sendRawItem(itemObj, userId, contextToken) {
543
+ const clientId = CryptoUtils.generateClientId();
544
+ const requestBody = {
545
+ msg: {
546
+ from_user_id: "",
547
+ // 留空,微信网关会自动通过 Bearer Token 识别机器人身份
548
+ to_user_id: userId,
549
+ client_id: clientId,
550
+ message_type: 2 /* BOT */,
551
+ message_state: 2 /* FINISH */,
552
+ context_token: contextToken,
553
+ item_list: [itemObj]
554
+ }
555
+ };
556
+ const response = await this.core.request("ilink/bot/sendmessage", {
557
+ method: "POST",
558
+ body: requestBody,
559
+ timeoutMs: DEFAULT_API_TIMEOUT_MS
560
+ });
561
+ return { clientId, response };
562
+ }
563
+ };
564
+
565
+ // src/managers/CdnManager.ts
566
+ var CdnManager = class {
567
+ core;
568
+ config;
569
+ constructor(core, config = DEFAULT_CDN_CONFIG) {
570
+ this.core = core;
571
+ this.config = mergeObjects(DEFAULT_CDN_CONFIG, config);
572
+ }
573
+ // ==========================================
574
+ // 核心流水线:上传二进制流到 CDN
575
+ // ==========================================
576
+ /**
577
+ * 将任意 Buffer 上传至微信 CDN,并返回发送消息所需的凭证
578
+ * @param buffer 文件的二进制原始数据
579
+ * @param toUserId 接收人的微信 ID (CDN 强制要求绑定)
580
+ * @param mediaType 媒体类型 (IMAGE, VIDEO, FILE, VOICE)
581
+ */
582
+ async uploadBuffer(buffer, toUserId, mediaType) {
583
+ const rawsize = buffer.length;
584
+ const rawfilemd5 = CryptoUtils.md5(buffer);
585
+ const filesize = CryptoUtils.getPaddedSize(rawsize);
586
+ const filekey = CryptoUtils.generateRandomKey(AES_BLOCK_SIZE).toString(
587
+ "hex"
588
+ );
589
+ const aeskeyBuffer = CryptoUtils.generateRandomKey(AES_BLOCK_SIZE);
590
+ const aeskeyHex = aeskeyBuffer.toString("hex");
591
+ const uploadUrlResp = await this.core.request(
592
+ "ilink/bot/getuploadurl",
593
+ {
594
+ method: "POST",
595
+ body: {
596
+ filekey,
597
+ media_type: mediaType,
598
+ to_user_id: toUserId,
599
+ rawsize,
600
+ rawfilemd5,
601
+ filesize,
602
+ no_need_thumb: true,
603
+ // 📝 [待重构]: 暂时忽略缩略图逻辑,全部设为 true
604
+ aeskey: aeskeyHex
605
+ },
606
+ timeoutMs: this.config.apiTimeoutMs
607
+ }
608
+ );
609
+ let cdnUrl = "";
610
+ if (uploadUrlResp.upload_full_url?.trim()) {
611
+ cdnUrl = uploadUrlResp.upload_full_url.trim();
612
+ } else if (uploadUrlResp.upload_param) {
613
+ cdnUrl = `${this.config.cdnBaseUrl}/upload?encrypted_query_param=${encodeURIComponent(uploadUrlResp.upload_param)}&filekey=${encodeURIComponent(filekey)}`;
614
+ } else {
615
+ throw new Error(
616
+ `[CdnManager] \u83B7\u53D6\u4E0A\u4F20\u5730\u5740\u5931\u8D25\uFF0CAPI \u54CD\u5E94\u7F3A\u5C11 full_url \u6216 upload_param`
617
+ );
618
+ }
619
+ const ciphertextBuffer = CryptoUtils.aesEcbEncrypt(buffer, aeskeyBuffer);
620
+ const encryptedQueryParam = await this.postToCdn(ciphertextBuffer, cdnUrl);
621
+ return {
622
+ filekey,
623
+ aeskeyHex,
624
+ aeskeyBuffer,
625
+ fileSizePlain: rawsize,
626
+ fileSizeCipher: filesize,
627
+ encryptedQueryParam
628
+ };
629
+ }
630
+ // ==========================================
631
+ // 私有发包器:专门应对奇葩的 CDN 接口
632
+ // ==========================================
633
+ /**
634
+ * 将密文 POST 到 CDN,并从 Response Header 中抠出下载参数。
635
+ * 包含 4xx 直接报错、5xx 重试的逻辑。
636
+ */
637
+ async postToCdn(ciphertext, cdnUrl) {
638
+ const MAX_RETRIES = this.config.maxRetry;
639
+ let lastError;
640
+ for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) {
641
+ try {
642
+ const res = await fetch(cdnUrl, {
643
+ method: "POST",
644
+ headers: { "Content-Type": "application/octet-stream" },
645
+ body: new Uint8Array(ciphertext)
646
+ });
647
+ if (res.status >= 400 && res.status < 500) {
648
+ const errMsg = res.headers.get(WECHAT_HTTP_HEADERS.ERROR_MSG) ?? await res.text();
649
+ throw new Error(`[CdnClientError] HTTP ${res.status}: ${errMsg}`);
650
+ }
651
+ if (res.status !== 200) {
652
+ const errMsg = res.headers.get(WECHAT_HTTP_HEADERS.ERROR_MSG) ?? `HTTP ${res.status}`;
653
+ throw new Error(`[CdnServerError]: ${errMsg}`);
654
+ }
655
+ const downloadParam = res.headers.get(
656
+ WECHAT_HTTP_HEADERS.ENCRYPTED_PARAM
657
+ );
658
+ if (!downloadParam) {
659
+ throw new Error(
660
+ `[CdnManager] \u4E0A\u4F20\u6210\u529F\uFF0C\u4F46\u54CD\u5E94\u5934\u4E2D\u7F3A\u5C11 ${WECHAT_HTTP_HEADERS.ENCRYPTED_PARAM}`
661
+ );
662
+ }
663
+ return downloadParam;
664
+ } catch (err) {
665
+ lastError = err;
666
+ if (err.message && err.message.includes("[CdnClientError]")) {
667
+ throw err;
668
+ }
669
+ if (attempt < MAX_RETRIES) {
670
+ await new Promise(
671
+ (resolve) => setTimeout(resolve, this.config.backoffBaseMs * attempt)
672
+ );
673
+ }
674
+ }
675
+ }
676
+ throw new Error(
677
+ `[CdnManager] CDN \u4E0A\u4F20\u5931\u8D25\uFF0C\u5DF2\u91CD\u8BD5 ${MAX_RETRIES} \u6B21\u3002\u6700\u540E\u9519\u8BEF: ${lastError?.message}`
678
+ );
679
+ }
680
+ // ==========================================
681
+ // 核心流水线:从 CDN 下载并解密
682
+ // ==========================================
683
+ /**
684
+ * 唯一对内暴露的下载引擎
685
+ * 负责拉取字节流并根据协议规范进行解密
686
+ */
687
+ async downloadBuffer(ticket) {
688
+ let url = "";
689
+ if (ticket.fullUrl) {
690
+ url = ticket.fullUrl;
691
+ } else if (ticket.encryptedQueryParam) {
692
+ url = `${this.config.cdnBaseUrl}/download?encrypted_query_param=${encodeURIComponent(ticket.encryptedQueryParam)}`;
693
+ } else {
694
+ throw new Error(
695
+ "[CdnManager] \u4E0B\u8F7D\u5931\u8D25\uFF1A\u7F3A\u5C11 fullUrl \u548C encryptedQueryParam"
696
+ );
697
+ }
698
+ const encryptedBuffer = await this.fetchCdnBytes(url);
699
+ if (ticket.isPlain || !ticket.aesKeyBase64) {
700
+ return encryptedBuffer;
701
+ }
702
+ const aesKey = this.parseWechatAesKey(ticket.aesKeyBase64);
703
+ return CryptoUtils.aesEcbDecrypt(encryptedBuffer, aesKey);
704
+ }
705
+ // ==========================================
706
+ // 私有辅助方法
707
+ // ==========================================
708
+ /**
709
+ * 处理微信极其不一致的密钥编码:
710
+ * 情况A: base64( raw 16 bytes )
711
+ * 情况B: base64( hex string of 16 bytes )
712
+ */
713
+ parseWechatAesKey(aesKeyBase64) {
714
+ const decoded = Buffer.from(aesKeyBase64, "base64");
715
+ if (decoded.length === 16) {
716
+ return decoded;
717
+ }
718
+ if (decoded.length === 32 && /^[0-9a-fA-F]{32}$/i.test(decoded.toString("ascii"))) {
719
+ return Buffer.from(decoded.toString("ascii"), "hex");
720
+ }
721
+ throw new Error(
722
+ `[CdnManager] \u65E0\u6CD5\u89E3\u6790\u7684 AES \u5BC6\u94A5\u683C\u5F0F (Base64="${aesKeyBase64}")`
723
+ );
724
+ }
725
+ /**
726
+ * 原生 fetch 拉取二进制字节流
727
+ */
728
+ async fetchCdnBytes(url) {
729
+ const res = await fetch(url);
730
+ if (!res.ok) {
731
+ const body = await res.text().catch(() => "(unreadable)");
732
+ throw new Error(
733
+ `[CdnManager] CDN \u4E0B\u8F7D\u7F51\u7EDC\u9519\u8BEF HTTP ${res.status}: ${body}`
734
+ );
735
+ }
736
+ return Buffer.from(await res.arrayBuffer());
737
+ }
738
+ };
739
+
740
+ // src/core/SilkConverter.ts
741
+ function pcmBytesToWav(pcm, sampleRate) {
742
+ const pcmBytes = pcm.byteLength;
743
+ const totalSize = 44 + pcmBytes;
744
+ const buf = Buffer.allocUnsafe(totalSize);
745
+ let offset = 0;
746
+ buf.write("RIFF", offset);
747
+ offset += 4;
748
+ buf.writeUInt32LE(totalSize - 8, offset);
749
+ offset += 4;
750
+ buf.write("WAVE", offset);
751
+ offset += 4;
752
+ buf.write("fmt ", offset);
753
+ offset += 4;
754
+ buf.writeUInt32LE(16, offset);
755
+ offset += 4;
756
+ buf.writeUInt16LE(1, offset);
757
+ offset += 2;
758
+ buf.writeUInt16LE(1, offset);
759
+ offset += 2;
760
+ buf.writeUInt32LE(sampleRate, offset);
761
+ offset += 4;
762
+ buf.writeUInt32LE(sampleRate * 2, offset);
763
+ offset += 4;
764
+ buf.writeUInt16LE(2, offset);
765
+ offset += 2;
766
+ buf.writeUInt16LE(16, offset);
767
+ offset += 2;
768
+ buf.write("data", offset);
769
+ offset += 4;
770
+ buf.writeUInt32LE(pcmBytes, offset);
771
+ offset += 4;
772
+ Buffer.from(pcm.buffer, pcm.byteOffset, pcm.byteLength).copy(buf, offset);
773
+ return buf;
774
+ }
775
+ async function silkToWav(silkBuffer, sampleRate = WECHAT_SILK_SAMPLE_RATE) {
776
+ try {
777
+ const { decode } = await import("silk-wasm");
778
+ const result = await decode(silkBuffer, sampleRate);
779
+ const wav = pcmBytesToWav(result.data, sampleRate);
780
+ return wav;
781
+ } catch (err) {
782
+ return null;
783
+ }
784
+ }
785
+
786
+ // src/managers/MessageParser.ts
787
+ import { writeFile } from "fs/promises";
788
+ var MessageParser = class {
789
+ cdn;
790
+ config;
791
+ constructor(cdn, config) {
792
+ this.cdn = cdn;
793
+ this.config = config;
794
+ }
795
+ /**
796
+ * 将微信底层的复杂 JSON 扁平化为开发者友好的对象
797
+ */
798
+ async parse(raw) {
799
+ if (!raw.item_list || raw.item_list.length === 0) return [];
800
+ const results = [];
801
+ const shared_data = {
802
+ messageId: String(raw.message_id),
803
+ seq: raw.seq,
804
+ fromUserId: raw.from_user_id,
805
+ toUserId: raw.to_user_id,
806
+ timestamp: raw.create_time_ms,
807
+ contextToken: raw.context_token,
808
+ msgType: 1 /* TEXT */,
809
+ msgTypeStr: "unknown",
810
+ index: 0,
811
+ raw
812
+ };
813
+ for (let i = 0; i < raw.item_list.length; i++) {
814
+ const item = raw.item_list[i];
815
+ const msgCopy = mergeObjects(shared_data, {
816
+ index: i,
817
+ msgType: item.type,
818
+ msgTypeStr: getItemType(item.type)
819
+ });
820
+ if (item.type === 1 /* TEXT */) {
821
+ msgCopy.text = item.text_item.text;
822
+ }
823
+ if (!isItemWithMedia(item.type)) {
824
+ results.push(msgCopy);
825
+ continue;
826
+ }
827
+ let cachedBuffer = null;
828
+ const ticket = this._extractDownloadTicket(item);
829
+ msgCopy.fileName = ticket?.originalFileName;
830
+ msgCopy.mediaTicket = ticket;
831
+ msgCopy.getBuffer = async () => {
832
+ if (cachedBuffer) return cachedBuffer;
833
+ if (!ticket) return null;
834
+ cachedBuffer = await this.cdn.downloadBuffer(ticket);
835
+ return cachedBuffer;
836
+ };
837
+ msgCopy.saveToFile = async (savePath) => {
838
+ const buf = await msgCopy.getBuffer();
839
+ if (!buf) throw new Error("\u65E0\u5A92\u4F53\u5185\u5BB9\u53EF\u4FDD\u5B58");
840
+ await writeFile(savePath, buf);
841
+ return savePath;
842
+ };
843
+ if (msgCopy.msgType === 3 /* VOICE */) {
844
+ msgCopy.getVoiceBuffer = msgCopy.getBuffer;
845
+ let pcmCachedBuffer = null;
846
+ msgCopy.getBuffer = async () => {
847
+ if (pcmCachedBuffer) return pcmCachedBuffer;
848
+ const silk_buffer = await msgCopy.getVoiceBuffer();
849
+ if (!silk_buffer) return null;
850
+ pcmCachedBuffer = await silkToWav(silk_buffer);
851
+ return pcmCachedBuffer;
852
+ };
853
+ }
854
+ if (this.config.autoDownloadMedia !== false && ticket) {
855
+ try {
856
+ msgCopy.getBuffer();
857
+ } catch (err) {
858
+ console.error(`\u81EA\u52A8\u4E0B\u8F7D\u5A92\u4F53\u5931\u8D25: ${err.message}`);
859
+ }
860
+ }
861
+ results.push(msgCopy);
862
+ }
863
+ return results;
864
+ }
865
+ /**
866
+ * 从原始 JSON 中提取标准化的 CDN 下载票据
867
+ */
868
+ _extractDownloadTicket(item) {
869
+ const file_item = getFileImageItem(item);
870
+ if (!file_item || !file_item.media) return void 0;
871
+ const media = file_item.media;
872
+ if (!media.encrypt_query_param && !media.full_url) return void 0;
873
+ let aesKeyBase64 = media.aes_key;
874
+ if (item.type === 2 /* IMAGE */ && "aeskey" in file_item && file_item.aeskey) {
875
+ aesKeyBase64 = Buffer.from(file_item.aeskey, "hex").toString("base64");
876
+ }
877
+ const isPlain = !aesKeyBase64;
878
+ return {
879
+ fullUrl: media.full_url,
880
+ encryptedQueryParam: media.encrypt_query_param,
881
+ aesKeyBase64,
882
+ isPlain,
883
+ originalFileName: "file_name" in file_item ? file_item.file_name : void 0
884
+ };
885
+ }
886
+ };
887
+
888
+ // src/WechatAiApi.ts
889
+ var WeChatApi = class extends EventEmitter {
890
+ core;
891
+ auth;
892
+ messages;
893
+ parser;
894
+ cdn;
895
+ // 内部缓存当前的登录凭证
896
+ currentCredentials = null;
897
+ isPolling = false;
898
+ syncBuf = "";
899
+ // 极其重要:状态同步游标
900
+ constructor(config = DEFAULT_CLIENT_CONFIG) {
901
+ super();
902
+ const mergedConfig = mergeObjects(DEFAULT_CLIENT_CONFIG, config);
903
+ this.core = new WeChatCore(mergedConfig);
904
+ this.auth = new AuthManager(this.core);
905
+ this.cdn = new CdnManager(this.core);
906
+ this.messages = new MessageManager(this.core, this.cdn);
907
+ this.parser = new MessageParser(this.cdn, mergedConfig);
908
+ if (mergedConfig.token) {
909
+ this.currentCredentials = {
910
+ token: mergedConfig.token,
911
+ baseUrl: mergedConfig.baseUrl,
912
+ accountId: "",
913
+ // 初始化时可能未知
914
+ userId: ""
915
+ // 初始化时可能未知
916
+ };
917
+ }
918
+ }
919
+ // ==========================================
920
+ // 1. 登录模块 (支持无脑调用 & 深度定制)
921
+ // ==========================================
922
+ /**
923
+ * 启动扫码登录流程。
924
+ * @param options 可选。如果为空,将使用默认的控制台交互体验。
925
+ */
926
+ async login(options = DEFAULT_LOGIN_OPTIONS) {
927
+ const defaultOptions = mergeObjects(DEFAULT_LOGIN_OPTIONS, options);
928
+ const result = await this.auth.login(defaultOptions);
929
+ this.currentCredentials = result;
930
+ this.emit("login", this.currentCredentials);
931
+ return this.currentCredentials;
932
+ }
933
+ // ==========================================
934
+ // 2. 凭证管理模块 (导入 / 导出 / 验证)
935
+ // ==========================================
936
+ /**
937
+ * 导出当前的登录凭证。
938
+ * 开发者拿到后可以自行保存到本地文件或数据库中。
939
+ */
940
+ exportCredentials() {
941
+ if (!this.currentCredentials || !this.currentCredentials.token) {
942
+ return null;
943
+ }
944
+ return { ...this.currentCredentials };
945
+ }
946
+ /**
947
+ * 加载现有的登录凭证。
948
+ * 适用于程序重启后,免扫码直接恢复状态。
949
+ */
950
+ loadCredentials(credentials) {
951
+ this.currentCredentials = { ...credentials };
952
+ this.core.setToken(credentials.token);
953
+ this.core.setBaseUrl(credentials.baseUrl);
954
+ this.core.setUserId(credentials.userId);
955
+ console.log(
956
+ `[WeChatApi] \u6210\u529F\u52A0\u8F7D\u51ED\u8BC1 (AccountID: ${credentials.accountId})`
957
+ );
958
+ }
959
+ /**
960
+ * 验证当前加载的凭证是否仍然有效。
961
+ * @returns boolean 是否有效
962
+ */
963
+ async verifyCredentials() {
964
+ if (!this.currentCredentials || !this.currentCredentials.token) {
965
+ return false;
966
+ }
967
+ try {
968
+ const response = await this.core.request("ilink/bot/getconfig", {
969
+ method: "POST",
970
+ body: {
971
+ ilink_user_id: this.currentCredentials.userId
972
+ },
973
+ timeoutMs: DEFAULT_REQUEST_TIMEOUT_MS
974
+ });
975
+ if (response.ret !== void 0 && response.ret !== 0) {
976
+ console.warn(
977
+ `[WeChatApi] \u51ED\u8BC1\u5DF2\u5931\u6548 (Server returned: ${response.errcode || response.ret})`
978
+ );
979
+ return false;
980
+ }
981
+ if (response.errcode !== void 0 && response.errcode !== 0) {
982
+ console.warn(
983
+ `[WeChatApi] \u51ED\u8BC1\u5DF2\u5931\u6548 (Server returned errcode: ${response.errcode}): ${response.errmsg}`
984
+ );
985
+ return false;
986
+ }
987
+ return true;
988
+ } catch (error) {
989
+ console.error(
990
+ "[WeChatApi] \u51ED\u8BC1\u9A8C\u8BC1\u5931\u8D25\uFF0C\u7F51\u7EDC\u5F02\u5E38\u6216\u5DF2\u8FC7\u671F:",
991
+ error.message
992
+ );
993
+ return false;
994
+ }
995
+ }
996
+ // ==========================================
997
+ // 轮询守护进程 (Daemon Loop)
998
+ // ==========================================
999
+ /**
1000
+ * 启动长轮询,监听新消息
1001
+ */
1002
+ async startPolling() {
1003
+ if (this.isPolling) {
1004
+ console.warn("[WeChatApi] \u8F6E\u8BE2\u5DF2\u7ECF\u5728\u8FD0\u884C\u4E2D\uFF0C\u8BF7\u52FF\u91CD\u590D\u542F\u52A8");
1005
+ return;
1006
+ }
1007
+ if (!this.currentCredentials?.token) {
1008
+ throw new Error("[WeChatApi] \u65E0\u6CD5\u542F\u52A8\u8F6E\u8BE2\uFF1A\u5C1A\u672A\u767B\u5F55\u6216\u672A\u52A0\u8F7D\u51ED\u8BC1");
1009
+ }
1010
+ this.isPolling = true;
1011
+ console.log("[WeChatApi] \u{1F680} \u8F6E\u8BE2\u5B88\u62A4\u8FDB\u7A0B\u5DF2\u542F\u52A8\uFF0C\u6B63\u5728\u76D1\u542C\u65B0\u6D88\u606F...");
1012
+ while (this.isPolling) {
1013
+ try {
1014
+ const response = await this.core.request(
1015
+ "ilink/bot/getupdates",
1016
+ {
1017
+ method: "POST",
1018
+ body: { get_updates_buf: this.syncBuf },
1019
+ timeoutMs: LONG_POLLING_TIMEOUT_MS
1020
+ // 长轮询标准超时时间
1021
+ }
1022
+ );
1023
+ const hasErrorRet = response.ret !== void 0 && response.ret !== 0;
1024
+ const hasErrcode = response.errcode !== void 0 && response.errcode !== 0;
1025
+ if (hasErrorRet || hasErrcode) {
1026
+ this.isPolling = false;
1027
+ console.log(response);
1028
+ this.emit(
1029
+ "error",
1030
+ new Error(`\u4F1A\u8BDD\u5DF2\u5931\u6548\u6216\u670D\u52A1\u7AEF\u62A5\u9519 (errcode: ${response.errcode})`)
1031
+ );
1032
+ console.error(`[WeChatApi] \u274C \u8F6E\u8BE2\u5F02\u5E38\u4E2D\u6B62\uFF1A${response.errmsg}`);
1033
+ break;
1034
+ }
1035
+ if (response.get_updates_buf) {
1036
+ this.syncBuf = response.get_updates_buf;
1037
+ }
1038
+ if (response.msgs && response.msgs.length > 0) {
1039
+ for (const rawMsg of response.msgs) {
1040
+ const parsedMsgs = await this.parser.parse(rawMsg);
1041
+ if (!parsedMsgs || parsedMsgs.length === 0) continue;
1042
+ for (const msg of parsedMsgs) {
1043
+ this.emit("message", msg);
1044
+ if (msg.msgTypeStr !== "unknown") {
1045
+ this.emit(msg.msgTypeStr, msg);
1046
+ }
1047
+ }
1048
+ }
1049
+ }
1050
+ } catch (error) {
1051
+ if (error.name === "AbortError" || error.message.includes("timeout")) {
1052
+ continue;
1053
+ }
1054
+ console.error(
1055
+ `[WeChatApi] \u26A0\uFE0F \u8F6E\u8BE2\u9047\u5230\u7F51\u7EDC\u5F02\u5E38\uFF0C3\u79D2\u540E\u91CD\u8BD5: ${error.message}`
1056
+ );
1057
+ await new Promise(
1058
+ (resolve) => setTimeout(resolve, POLLING_ERROR_RETRY_DELAY_MS)
1059
+ );
1060
+ }
1061
+ }
1062
+ console.log("[WeChatApi] \u{1F6D1} \u8F6E\u8BE2\u5B88\u62A4\u8FDB\u7A0B\u5DF2\u505C\u6B62\u3002");
1063
+ }
1064
+ /**
1065
+ * 停止长轮询
1066
+ */
1067
+ stopPolling() {
1068
+ this.isPolling = false;
1069
+ }
1070
+ };
1071
+ export {
1072
+ LoginStatus,
1073
+ WeChatApi
1074
+ };
7
1075
  //# sourceMappingURL=index.js.map