返回 AiToEarn
wechat.service.ts
1 import type { AxiosError, AxiosInstance, AxiosResponse } from 'axios'
2 import { createHash } from 'node:crypto'
3 import { Injectable, Logger } from '@nestjs/common'
4 import { AccountType, AppException, ResponseCode } from '@yikart/common'
5 import axios from 'axios'
6 import { z } from 'zod'
7 import { categoryFromHttpStatus, isHttpStatusRetryable, isNetworkErrorCode } from '../../utils/platform-error-classifier.util'
8 import { categoryFromWeChatErrCode, isWeChatErrCodeRetryable } from '../../utils/wechat-error.util'
9 import { isAvailablePlatformConfig } from '../platforms.config'
10 import { ChannelPlatformException, PlatformErrorCategory, PlatformErrorCauseType } from '../platforms.exception'
11 import { WechatChannelsConfig, WechatConfig, WechatOfficialConfig } from './wechat.config'
12
13 interface WeChatApiErrorResponse {
14 errcode?: number
15 errmsg?: string
16 }
17
18 interface WeChatEndpointConfig {
19 url?: string
20 method?: string
21 baseURL?: string
22 }
23
24 interface WeChatAccessTokenResponse {
25 access_token: string
26 expires_in: number
27 errcode?: number
28 errmsg?: string
29 }
30
31 export interface WeChatChannelsFinderVideoInfo {
32 errcode?: number
33 errmsg?: string
34 finder_id?: string
35 video_id?: string
36 title?: string
37 description?: string
38 cover_url?: string
39 publish_time?: number | string
40 create_time?: number | string
41 read_count?: number
42 like_count?: number
43 comment_count?: number
44 share_count?: number
45 }
46
47 export interface WeChatChannelsLinkInfo {
48 errcode?: number
49 errmsg?: string
50 finder_id?: string
51 video_id?: string
52 }
53
54 interface WeChatUserInfoResponse {
55 openid: string
56 nickname: string
57 headimgurl?: string
58 unionid?: string
59 errcode?: number
60 errmsg?: string
61 }
62
63 interface WeChatJsapiTicketResponse {
64 ticket: string
65 expires_in: number
66 errcode?: number
67 errmsg?: string
68 }
69
70 interface WeChatDraftAddResponse {
71 media_id: string
72 errcode?: number
73 errmsg?: string
74 }
75
76 interface WeChatFreePublishResponse {
77 publish_id: string
78 errcode?: number
79 errmsg?: string
80 }
81
82 interface WeChatMediaUploadResponse {
83 media_id: string
84 url?: string
85 errcode?: number
86 errmsg?: string
87 }
88
89 interface WeChatMaterialCountResponse {
90 voice_count: number
91 video_count: number
92 image_count: number
93 news_count: number
94 errcode?: number
95 errmsg?: string
96 }
97
98 interface WeChatUserCumulateItem {
99 ref_date?: string
100 cumulate_user?: number
101 }
102
103 interface WeChatUserCumulateResponse {
104 errcode?: number
105 errmsg?: string
106 list?: WeChatUserCumulateItem[]
107 }
108
109 interface WeChatUserReadItem {
110 ref_date?: string
111 user_source?: number
112 int_page_read_user?: number
113 int_page_read_count?: number
114 ori_page_read_user?: number
115 ori_page_read_count?: number
116 share_user?: number
117 share_count?: number
118 add_to_fav_user?: number
119 add_to_fav_count?: number
120 }
121
122 interface WeChatUserReadResponse {
123 errcode?: number
124 errmsg?: string
125 list?: WeChatUserReadItem[]
126 }
127
128 interface WeChatChannelsUserAttr {
129 nickname?: string
130 username?: string
131 encryptedUsername?: string
132 encryptedHeadImage?: string
133 city?: string
134 province?: string
135 country?: string
136 sex?: number
137 }
138
139 interface WeChatChannelsFinderUser {
140 finderUsername?: string
141 nickname?: string
142 headImgUrl?: string
143 coverImgUrl?: string
144 acctType?: number
145 authIconType?: number
146 adminNickname?: string
147 feedsCount?: number | string
148 fansCount?: number | string
149 uniqId?: string
150 isMasterFinder?: boolean
151 }
152
153 interface WeChatChannelsAuthDataPayload {
154 userAttr?: WeChatChannelsUserAttr
155 finderUser?: WeChatChannelsFinderUser
156 }
157
158 interface WeChatChannelsAuthDataResponse {
159 errCode?: number
160 errMsg?: string
161 errcode?: number
162 errmsg?: string
163 err_code?: number
164 err_msg?: string
165 code?: number
166 message?: string
167 base_resp?: {
168 ret?: number
169 err_msg?: string
170 }
171 data?: WeChatChannelsAuthDataPayload
172 }
173
174 interface BrowserCookieItem {
175 name?: string
176 value?: string
177 }
178
179 const WeChatChannelsUserAttrSchema = z.object({
180 nickname: z.string().optional(),
181 username: z.string().optional(),
182 encryptedUsername: z.string().optional(),
183 encryptedHeadImage: z.string().optional(),
184 city: z.string().optional(),
185 province: z.string().optional(),
186 country: z.string().optional(),
187 sex: z.number().optional(),
188 })
189
190 const WeChatChannelsFinderUserSchema = z.object({
191 finderUsername: z.string().optional(),
192 nickname: z.string().optional(),
193 headImgUrl: z.string().optional(),
194 coverImgUrl: z.string().optional(),
195 acctType: z.number().optional(),
196 authIconType: z.number().optional(),
197 adminNickname: z.string().optional(),
198 feedsCount: z.union([z.number(), z.string()]).optional(),
199 fansCount: z.union([z.number(), z.string()]).optional(),
200 uniqId: z.string().optional(),
201 isMasterFinder: z.boolean().optional(),
202 })
203
204 const WeChatChannelsAuthDataResponseSchema = z.object({
205 errCode: z.number().optional(),
206 errMsg: z.string().optional(),
207 errcode: z.number().optional(),
208 errmsg: z.string().optional(),
209 err_code: z.number().optional(),
210 err_msg: z.string().optional(),
211 code: z.number().optional(),
212 message: z.string().optional(),
213 base_resp: z.object({
214 ret: z.number().optional(),
215 err_msg: z.string().optional(),
216 }).optional(),
217 data: z.object({
218 userAttr: WeChatChannelsUserAttrSchema.optional(),
219 finderUser: WeChatChannelsFinderUserSchema.optional(),
220 }).optional(),
221 })
222
223 export interface WeChatChannelsAuthData {
224 uid?: string
225 nickname?: string
226 avatar?: string
227 fansCount?: number
228 followingCount?: number
229 readCount?: number
230 likeCount?: number
231 collectCount?: number
232 forwardCount?: number
233 commentCount?: number
234 workCount?: number
235 raw: WeChatChannelsAuthDataResponse
236 }
237
238 export interface WeChatArticle {
239 article_type?: 'news'
240 title: string
241 author?: string
242 digest?: string
243 content: string
244 thumb_media_id: string
245 show_cover_pic?: number
246 thumb_url?: string
247 need_open_comment?: number
248 only_fans_can_comment?: number
249 content_source_url?: string
250 }
251
252 @Injectable()
253 export class WeChatService {
254 private readonly logger = new Logger(WeChatService.name)
255 private readonly httpClient: AxiosInstance
256 private officialAccessTokenCache: { token: string, expiresAt: number } | null = null
257 private channelsAccessTokenCache: { token: string, expiresAt: number } | null = null
258
259 constructor(private readonly cfg: WechatConfig) {
260 this.httpClient = axios.create({ timeout: 30000 })
261 this.httpClient.interceptors.response.use(
262 (response) => {
263 this.throwIfWeChatApiError(response)
264 return response
265 },
266 (error: AxiosError<WeChatApiErrorResponse>) => {
267 throw this.fromAxiosError(error)
268 },
269 )
270 }
271
272 private get officialConfig(): WechatOfficialConfig {
273 if (!isAvailablePlatformConfig(this.cfg.official)) {
274 throw new AppException(ResponseCode.PlatformNotSupported, { platform: AccountType.WeChatOfficial })
275 }
276 return this.cfg.official
277 }
278
279 private get channelsConfig(): WechatChannelsConfig {
280 if (!isAvailablePlatformConfig(this.cfg.channels)) {
281 throw new AppException(ResponseCode.PlatformNotSupported, { platform: AccountType.WeChatChannels })
282 }
283 return this.cfg.channels
284 }
285
286 // ── Official Account OAuth2 ──
287
288 generateOfficialAuthUrl(redirectUri: string, state: string, scope: string): string {
289 const params = new URLSearchParams({
290 appid: this.officialConfig.appId,
291 redirect_uri: redirectUri,
292 response_type: 'code',
293 scope,
294 state,
295 })
296 return `https://open.weixin.qq.com/connect/oauth2/authorize?${params.toString()}#wechat_redirect`
297 }
298
299 async exchangeOfficialCode(code: string): Promise<{
300 accessToken: string
301 refreshToken: string
302 expiresIn: number
303 openId: string
304 unionId?: string
305 scope: string
306 }> {
307 const params = new URLSearchParams({
308 appid: this.officialConfig.appId,
309 secret: this.officialConfig.appSecret,
310 code,
311 grant_type: 'authorization_code',
312 })
313
314 const response = await this.httpClient.get<{
315 access_token: string
316 refresh_token: string
317 expires_in: number
318 openid: string
319 unionid?: string
320 scope: string
321 errcode?: number
322 errmsg?: string
323 }>(`https://api.weixin.qq.com/sns/oauth2/access_token?${params.toString()}`)
324
325 return {
326 accessToken: response.data.access_token,
327 refreshToken: response.data.refresh_token,
328 expiresIn: response.data.expires_in,
329 openId: response.data.openid,
330 unionId: response.data.unionid,
331 scope: response.data.scope,
332 }
333 }
334
335 async refreshOfficialToken(refreshToken: string): Promise<{
336 accessToken: string
337 refreshToken: string
338 expiresIn: number
339 scope: string
340 }> {
341 const params = new URLSearchParams({
342 appid: this.officialConfig.appId,
343 grant_type: 'refresh_token',
344 refresh_token: refreshToken,
345 })
346
347 const response = await this.httpClient.get<{
348 access_token: string
349 refresh_token: string
350 expires_in: number
351 scope: string
352 errcode?: number
353 errmsg?: string
354 }>(`https://api.weixin.qq.com/sns/oauth2/refresh_token?${params.toString()}`)
355
356 return {
357 accessToken: response.data.access_token,
358 refreshToken: response.data.refresh_token,
359 expiresIn: response.data.expires_in,
360 scope: response.data.scope,
361 }
362 }
363
364 async getOfficialUserInfo(accessToken: string, openId: string): Promise<{
365 openId: string
366 nickname: string
367 avatarUrl?: string
368 unionId?: string
369 }> {
370 const params = new URLSearchParams({
371 access_token: accessToken,
372 openid: openId,
373 lang: 'zh_CN',
374 })
375
376 const response = await this.httpClient.get<WeChatUserInfoResponse>(
377 `https://api.weixin.qq.com/sns/userinfo?${params.toString()}`,
378 )
379
380 return {
381 openId: response.data.openid,
382 nickname: response.data.nickname,
383 avatarUrl: response.data.headimgurl,
384 unionId: response.data.unionid,
385 }
386 }
387
388 // ── Official Account API (server-side token) ──
389
390 async getOfficialAccessToken(): Promise<string> {
391 const now = Date.now()
392 if (this.officialAccessTokenCache && this.officialAccessTokenCache.expiresAt > now) {
393 return this.officialAccessTokenCache.token
394 }
395
396 const params = new URLSearchParams({
397 grant_type: 'client_credential',
398 appid: this.officialConfig.appId,
399 secret: this.officialConfig.appSecret,
400 })
401
402 const response = await this.httpClient.get<WeChatAccessTokenResponse>(
403 `https://api.weixin.qq.com/cgi-bin/token?${params.toString()}`,
404 )
405
406 this.officialAccessTokenCache = {
407 token: response.data.access_token,
408 expiresAt: now + (response.data.expires_in - 300) * 1000,
409 }
410
411 return response.data.access_token
412 }
413
414 async addDraft(articles: WeChatArticle[]): Promise<string> {
415 const accessToken = await this.getOfficialAccessToken()
416
417 const response = await this.httpClient.post<WeChatDraftAddResponse>(
418 `https://api.weixin.qq.com/cgi-bin/draft/add?access_token=${accessToken}`,
419 { articles },
420 )
421
422 return response.data.media_id
423 }
424
425 async freePublish(mediaId: string): Promise<string> {
426 const accessToken = await this.getOfficialAccessToken()
427
428 const response = await this.httpClient.post<WeChatFreePublishResponse>(
429 `https://api.weixin.qq.com/cgi-bin/freepublish/submit?access_token=${accessToken}`,
430 { media_id: mediaId },
431 )
432
433 return response.data.publish_id
434 }
435
436 async getPublishStatus(publishId: string): Promise<{
437 publishStatus: number
438 articleId?: string
439 articleUrl?: string
440 }> {
441 const accessToken = await this.getOfficialAccessToken()
442
443 const response = await this.httpClient.post<{
444 publish_id: string
445 publish_status: number
446 article_id?: string
447 article_url?: string
448 errcode?: number
449 errmsg?: string
450 }>(`https://api.weixin.qq.com/cgi-bin/freepublish/get?access_token=${accessToken}`, {
451 publish_id: publishId,
452 })
453
454 return {
455 publishStatus: response.data.publish_status,
456 articleId: response.data.article_id,
457 articleUrl: response.data.article_url,
458 }
459 }
460
461 async uploadImage(imageBuffer: Buffer, filename: string): Promise<string> {
462 const accessToken = await this.getOfficialAccessToken()
463 const formData = new FormData()
464 formData.append('media', new Blob([new Uint8Array(imageBuffer)]), filename)
465
466 const response = await this.httpClient.post<WeChatMediaUploadResponse>(
467 `https://api.weixin.qq.com/cgi-bin/media/uploadimg?access_token=${accessToken}`,
468 formData,
469 { headers: { 'Content-Type': 'multipart/form-data' } },
470 )
471
472 if (!response.data.url) {
473 throw new ChannelPlatformException({
474 code: ResponseCode.ChannelPlatformMediaProcessingFailed,
475 platform: AccountType.WeChatOfficial,
476 category: PlatformErrorCategory.Validation,
477 context: { endpoint: 'uploadImage' },
478 cause: {
479 type: PlatformErrorCauseType.Validation,
480 platformMessage: 'Missing image url in uploadImage response',
481 raw: response.data,
482 },
483 })
484 }
485
486 return response.data.url
487 }
488
489 async uploadThumbImage(imageBuffer: Buffer, filename: string): Promise<string> {
490 const accessToken = await this.getOfficialAccessToken()
491 const formData = new FormData()
492 formData.append('media', new Blob([new Uint8Array(imageBuffer)]), filename)
493
494 const response = await this.httpClient.post<WeChatMediaUploadResponse>(
495 `https://api.weixin.qq.com/cgi-bin/material/add_material?access_token=${accessToken}&type=image`,
496 formData,
497 { headers: { 'Content-Type': 'multipart/form-data' } },
498 )
499
500 return response.data.media_id
501 }
502
503 async getMaterialCount(): Promise<WeChatMaterialCountResponse> {
504 const accessToken = await this.getOfficialAccessToken()
505
506 const response = await this.httpClient.get<WeChatMaterialCountResponse>(
507 `https://api.weixin.qq.com/cgi-bin/material/get_materialcount?access_token=${accessToken}`,
508 )
509
510 return response.data
511 }
512
513 async getUserCumulate(
514 accessToken: string,
515 beginDate: string,
516 endDate: string,
517 ): Promise<WeChatUserCumulateResponse> {
518 const response = await this.httpClient.post<WeChatUserCumulateResponse>(
519 `https://api.weixin.qq.com/datacube/getusercumulate?access_token=${accessToken}`,
520 {
521 begin_date: beginDate,
522 end_date: endDate,
523 },
524 )
525 return response.data
526 }
527
528 async getUserRead(
529 accessToken: string,
530 beginDate: string,
531 endDate: string,
532 ): Promise<WeChatUserReadResponse> {
533 const response = await this.httpClient.post<WeChatUserReadResponse>(
534 `https://api.weixin.qq.com/datacube/getuserread?access_token=${accessToken}`,
535 {
536 begin_date: beginDate,
537 end_date: endDate,
538 },
539 )
540 return response.data
541 }
542
543 async getJsapiTicket(): Promise<string> {
544 const accessToken = await this.getOfficialAccessToken()
545
546 const response = await this.httpClient.get<WeChatJsapiTicketResponse>(
547 `https://api.weixin.qq.com/cgi-bin/ticket/getticket?access_token=${accessToken}&type=jsapi`,
548 )
549
550 return response.data.ticket
551 }
552
553 // ── Channels (视频号) API ──
554
555 async getChannelsAccessToken(): Promise<string> {
556 const now = Date.now()
557 if (this.channelsAccessTokenCache && this.channelsAccessTokenCache.expiresAt > now) {
558 return this.channelsAccessTokenCache.token
559 }
560
561 const response = await this.httpClient.post<WeChatAccessTokenResponse>(
562 'https://api.weixin.qq.com/cgi-bin/token',
563 {
564 grant_type: 'client_credential',
565 appid: this.channelsConfig.appId,
566 secret: this.channelsConfig.appSecret,
567 },
568 )
569
570 this.channelsAccessTokenCache = {
571 token: response.data.access_token,
572 expiresAt: now + (response.data.expires_in - 300) * 1000,
573 }
574
575 return response.data.access_token
576 }
577
578 async channelsGetFinderVideoInfo(finderId: string, videoId: string): Promise<WeChatChannelsFinderVideoInfo> {
579 const accessToken = await this.getChannelsAccessToken()
580
581 const response = await this.httpClient.post<WeChatChannelsFinderVideoInfo>(
582 `https://api.weixin.qq.com/channels/ec/finder/video/get?access_token=${accessToken}`,
583 { finder_id: finderId, video_id: videoId },
584 )
585
586 return response.data
587 }
588
589 async channelsCreateVideoDraft(params: {
590 title: string
591 description?: string
592 videoMediaId: string
593 coverMediaId?: string
594 }): Promise<string> {
595 const accessToken = await this.getChannelsAccessToken()
596
597 const response = await this.httpClient.post<{
598 errcode?: number
599 errmsg?: string
600 media_id?: string
601 }>(
602 `https://api.weixin.qq.com/channels/medias/uploadvideo?access_token=${accessToken}`,
603 {
604 title: params.title,
605 description: params.description ?? '',
606 media_id: params.videoMediaId,
607 cover_media_id: params.coverMediaId,
608 },
609 )
610
611 return response.data.media_id ?? ''
612 }
613
614 async channelsPublishVideo(mediaId: string): Promise<string> {
615 const accessToken = await this.getChannelsAccessToken()
616
617 const response = await this.httpClient.post<{
618 errcode?: number
619 errmsg?: string
620 publish_id?: string
621 }>(
622 `https://api.weixin.qq.com/channels/medias/publish?access_token=${accessToken}`,
623 { media_id: mediaId },
624 )
625
626 return response.data.publish_id ?? ''
627 }
628
629 async channelsGetPublishStatus(publishId: string): Promise<{
630 status: number
631 videoId?: string
632 finderId?: string
633 }> {
634 const accessToken = await this.getChannelsAccessToken()
635
636 const response = await this.httpClient.post<{
637 errcode?: number
638 errmsg?: string
639 status?: number
640 video_id?: string
641 finder_id?: string
642 }>(
643 `https://api.weixin.qq.com/channels/medias/publish/status?access_token=${accessToken}`,
644 { publish_id: publishId },
645 )
646
647 return {
648 status: response.data.status ?? 0,
649 videoId: response.data.video_id,
650 finderId: response.data.finder_id,
651 }
652 }
653
654 async channelsUploadVideo(videoBuffer: Buffer, filename: string): Promise<string> {
655 const accessToken = await this.getChannelsAccessToken()
656 const formData = new FormData()
657 formData.append('media', new Blob([new Uint8Array(videoBuffer)]), filename)
658
659 const response = await this.httpClient.post<{
660 errcode?: number
661 errmsg?: string
662 media_id?: string
663 }>(
664 `https://api.weixin.qq.com/channels/medias/upload?access_token=${accessToken}`,
665 formData,
666 { headers: { 'Content-Type': 'multipart/form-data' } },
667 )
668
669 return response.data.media_id ?? ''
670 }
671
672 async channelsUploadCover(imageBuffer: Buffer, filename: string): Promise<string> {
673 const accessToken = await this.getChannelsAccessToken()
674 const formData = new FormData()
675 formData.append('media', new Blob([new Uint8Array(imageBuffer)]), filename)
676
677 const response = await this.httpClient.post<{
678 errcode?: number
679 errmsg?: string
680 media_id?: string
681 }>(
682 `https://api.weixin.qq.com/channels/medias/upload?access_token=${accessToken}`,
683 formData,
684 { headers: { 'Content-Type': 'multipart/form-data' } },
685 )
686
687 return response.data.media_id ?? ''
688 }
689
690 async channelsGetLinkInfo(link: string): Promise<WeChatChannelsLinkInfo> {
691 const accessToken = await this.getChannelsAccessToken()
692
693 const response = await this.httpClient.post<WeChatChannelsLinkInfo>(
694 `https://api.weixin.qq.com/channels/ec/finder/link/info?access_token=${accessToken}`,
695 { link },
696 )
697
698 return response.data
699 }
700
701 async getChannelsAuthData(loginCookie: string): Promise<WeChatChannelsAuthData> {
702 const cookie = this.buildCookieHeader(loginCookie)
703 const cookieMap = this.parseCookieHeader(cookie)
704 if (!cookieMap['sessionid']) {
705 throw new ChannelPlatformException({
706 code: ResponseCode.ChannelAccountInfoFailed,
707 platform: AccountType.WeChatChannels,
708 category: PlatformErrorCategory.Auth,
709 context: { endpoint: 'POST /cgi-bin/mmfinderassistant-bin/auth/auth_data' },
710 cause: {
711 type: PlatformErrorCauseType.Validation,
712 platformMessage: 'Missing sessionid in WeChat Channels loginCookie',
713 },
714 retryable: false,
715 })
716 }
717
718 let data: WeChatChannelsAuthDataResponse
719 const requestBody = {
720 timestamp: String(Date.now()),
721 _log_finder_uin: '',
722 _log_finder_id: '',
723 rawKeyBuff: null,
724 pluginSessionId: null,
725 scene: 7,
726 reqScene: 7,
727 }
728 const params = {
729 _aid: this.buildChannelsRequestHash(cookie),
730 _rid: this.buildChannelsRequestHash(`${cookie}:${Date.now()}`),
731 _pageUrl: 'https://channels.weixin.qq.com/platform',
732 }
733 try {
734 const response = await axios.post<WeChatChannelsAuthDataResponse>(
735 'https://channels.weixin.qq.com/cgi-bin/mmfinderassistant-bin/auth/auth_data',
736 requestBody,
737 {
738 timeout: 30000,
739 params,
740 headers: {
741 'Origin': 'https://channels.weixin.qq.com',
742 'Referer': 'https://channels.weixin.qq.com/platform',
743 'Cookie': cookie,
744 ...(cookieMap['wxuin'] && { 'X-WECHAT-UIN': cookieMap['wxuin'] }),
745 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36',
746 'Content-Type': 'application/json',
747 'Accept': '*/*',
748 'Host': 'channels.weixin.qq.com',
749 },
750 },
751 )
752 data = this.parseChannelsAuthDataResponse(response.data)
753 }
754 catch (error) {
755 this.logger.error(error, 'WeChat Channels auth_data request failed')
756 if (error instanceof ChannelPlatformException) {
757 throw error
758 }
759 if (axios.isAxiosError<WeChatChannelsAuthDataResponse>(error) || error instanceof Error) {
760 throw this.fromChannelsAuthDataError(error)
761 }
762 throw this.fromChannelsAuthDataUnexpectedError()
763 }
764
765 this.throwIfChannelsAuthDataError(data)
766 return this.parseChannelsAuthData(data)
767 }
768
769 // ── Helpers ──
770
771 private parseChannelsAuthDataResponse(data: unknown): WeChatChannelsAuthDataResponse {
772 const parsed = WeChatChannelsAuthDataResponseSchema.safeParse(data)
773 if (parsed.success) {
774 return parsed.data
775 }
776
777 throw new ChannelPlatformException({
778 code: ResponseCode.ChannelPlatformResponseInvalid,
779 platform: AccountType.WeChatChannels,
780 category: PlatformErrorCategory.Validation,
781 context: { endpoint: 'POST /cgi-bin/mmfinderassistant-bin/auth/auth_data' },
782 cause: {
783 type: PlatformErrorCauseType.Platform,
784 platformMessage: 'Invalid WeChat Channels auth_data response',
785 raw: { issues: parsed.error.issues, data },
786 },
787 retryable: false,
788 })
789 }
790
791 private parseChannelsAuthData(data: WeChatChannelsAuthDataResponse): WeChatChannelsAuthData {
792 const finderUser = data.data?.finderUser
793 const userAttr = data.data?.userAttr
794 const uid = finderUser?.uniqId
795
796 return {
797 uid,
798 nickname: finderUser?.nickname ?? userAttr?.nickname ?? uid,
799 avatar: finderUser?.headImgUrl ?? userAttr?.encryptedHeadImage,
800 fansCount: this.toCount(finderUser?.fansCount),
801 workCount: this.toCount(finderUser?.feedsCount),
802 raw: data,
803 }
804 }
805
806 private throwIfChannelsAuthDataError(data: WeChatChannelsAuthDataResponse): void {
807 const code = data.errCode ?? data.errcode ?? data.err_code ?? data.code ?? data.base_resp?.ret ?? 0
808 if (code === 0) {
809 return
810 }
811
812 throw new ChannelPlatformException({
813 code: ResponseCode.ChannelAccountInfoFailed,
814 platform: AccountType.WeChatChannels,
815 category: categoryFromWeChatErrCode(code),
816 context: { endpoint: 'POST /cgi-bin/mmfinderassistant-bin/auth/auth_data' },
817 cause: {
818 type: PlatformErrorCauseType.Platform,
819 platformCode: code,
820 platformMessage: data.errMsg ?? data.errmsg ?? data.err_msg ?? data.message ?? data.base_resp?.err_msg ?? 'Unknown error',
821 raw: data,
822 },
823 retryable: isWeChatErrCodeRetryable(code),
824 })
825 }
826
827 private fromChannelsAuthDataError(error: Error | AxiosError<WeChatChannelsAuthDataResponse>): ChannelPlatformException {
828 if (axios.isAxiosError<WeChatChannelsAuthDataResponse>(error)) {
829 const data = error.response?.data
830 return new ChannelPlatformException({
831 code: ResponseCode.ChannelAccountInfoFailed,
832 platform: AccountType.WeChatChannels,
833 category: error.response
834 ? categoryFromHttpStatus(error.response.status)
835 : PlatformErrorCategory.Network,
836 context: { endpoint: 'POST /cgi-bin/mmfinderassistant-bin/auth/auth_data' },
837 cause: {
838 type: error.response
839 ? PlatformErrorCauseType.Http
840 : (isNetworkErrorCode(error.code) ? PlatformErrorCauseType.Network : PlatformErrorCauseType.Unknown),
841 httpStatus: error.response?.status,
842 platformCode: data?.errCode ?? data?.errcode ?? data?.err_code ?? data?.code,
843 platformMessage: data?.errMsg ?? data?.errmsg ?? data?.err_msg ?? data?.message ?? error.message,
844 raw: data ?? { message: error.message, code: error.code },
845 },
846 retryable: error.response ? isHttpStatusRetryable(error.response.status) : true,
847 })
848 }
849
850 return new ChannelPlatformException({
851 code: ResponseCode.ChannelAccountInfoFailed,
852 platform: AccountType.WeChatChannels,
853 category: PlatformErrorCategory.Unknown,
854 context: { endpoint: 'POST /cgi-bin/mmfinderassistant-bin/auth/auth_data' },
855 cause: {
856 type: PlatformErrorCauseType.Unknown,
857 platformMessage: error instanceof Error ? error.message : 'Unknown error',
858 },
859 retryable: false,
860 })
861 }
862
863 private fromChannelsAuthDataUnexpectedError(): ChannelPlatformException {
864 return new ChannelPlatformException({
865 code: ResponseCode.ChannelAccountInfoFailed,
866 platform: AccountType.WeChatChannels,
867 category: PlatformErrorCategory.Unknown,
868 context: { endpoint: 'POST /cgi-bin/mmfinderassistant-bin/auth/auth_data' },
869 cause: {
870 type: PlatformErrorCauseType.Unknown,
871 platformMessage: 'Unexpected auth_data error',
872 },
873 retryable: false,
874 })
875 }
876
877 private buildCookieHeader(loginCookie: string): string {
878 const raw = this.sanitizeCookieHeaderValue(loginCookie.trim(), 'loginCookie')
879 if (!raw.startsWith('[')) {
880 return raw
881 }
882
883 try {
884 const cookies = JSON.parse(raw) as BrowserCookieItem[]
885 if (!Array.isArray(cookies)) {
886 return raw
887 }
888
889 return cookies
890 .map((cookie) => {
891 if (!cookie || typeof cookie.name !== 'string' || typeof cookie.value !== 'string') {
892 return ''
893 }
894 const name = this.sanitizeCookieHeaderValue(cookie.name, 'cookie.name').trim()
895 const value = this.sanitizeCookieHeaderValue(cookie.value, 'cookie.value').trim()
896 return `${name}=${value}`
897 })
898 .filter(Boolean)
899 .join('; ')
900 }
901 catch {
902 return raw
903 }
904 }
905
906 private sanitizeCookieHeaderValue(value: string, field: string): string {
907 if (/[\r\n]/.test(value)) {
908 throw new ChannelPlatformException({
909 code: ResponseCode.ChannelAccountInfoFailed,
910 platform: AccountType.WeChatChannels,
911 category: PlatformErrorCategory.Validation,
912 context: { endpoint: 'POST /cgi-bin/mmfinderassistant-bin/auth/auth_data' },
913 cause: {
914 type: PlatformErrorCauseType.Validation,
915 platformMessage: `Invalid newline in WeChat Channels ${field}`,
916 },
917 retryable: false,
918 })
919 }
920 return value
921 }
922
923 private parseCookieHeader(cookie: string): Record<string, string> {
924 return cookie
925 .split(';')
926 .map(part => part.trim())
927 .filter(Boolean)
928 .reduce<Record<string, string>>((result, item) => {
929 const separatorIndex = item.indexOf('=')
930 if (separatorIndex <= 0) {
931 return result
932 }
933 result[item.slice(0, separatorIndex).trim()] = item.slice(separatorIndex + 1).trim()
934 return result
935 }, {})
936 }
937
938 private buildChannelsRequestHash(cookie: string): string {
939 return createHash('md5').update(cookie).digest('hex')
940 }
941
942 private toCount(value?: number | string): number | undefined {
943 if (value === undefined || value === '') {
944 return undefined
945 }
946 return Number(value)
947 }
948
949 private throwIfWeChatApiError(response: AxiosResponse<WeChatApiErrorResponse>): void {
950 const data = response.data
951 if (data?.errcode && data.errcode !== 0) {
952 const endpoint = this.endpointFromConfig(response.config)
953 const exception = new ChannelPlatformException({
954 code: this.codeFromEndpoint(endpoint),
955 platform: this.platformFromEndpoint(endpoint),
956 category: categoryFromWeChatErrCode(data.errcode),
957 context: { endpoint },
958 cause: {
959 type: PlatformErrorCauseType.Platform,
960 platformCode: data.errcode,
961 platformMessage: data.errmsg ?? 'Unknown error',
962 raw: data,
963 },
964 retryable: isWeChatErrCodeRetryable(data.errcode),
965 })
966 this.logger.error(exception, `WeChat API error ${endpoint ?? 'unresolved endpoint'}`)
967 throw exception
968 }
969 }
970
971 private fromAxiosError(error: AxiosError<WeChatApiErrorResponse>): ChannelPlatformException {
972 const response = error.response
973 const data = response?.data
974 const endpoint = this.endpointFromConfig(response?.config ?? error.config)
975
976 return new ChannelPlatformException({
977 code: this.codeFromEndpoint(endpoint),
978 platform: this.platformFromEndpoint(endpoint),
979 category: response
980 ? categoryFromHttpStatus(response.status)
981 : PlatformErrorCategory.Network,
982 context: { endpoint },
983 cause: {
984 type: response
985 ? PlatformErrorCauseType.Http
986 : (isNetworkErrorCode(error.code) ? PlatformErrorCauseType.Network : PlatformErrorCauseType.Unknown),
987 httpStatus: response?.status,
988 platformCode: data?.errcode,
989 platformMessage: data?.errmsg ?? error.message,
990 raw: data ?? error.toJSON(),
991 },
992 retryable: response ? isHttpStatusRetryable(response.status) : true,
993 })
994 }
995
996 private endpointFromConfig(config?: WeChatEndpointConfig): string | undefined {
997 if (!config?.url) {
998 return undefined
999 }
1000 const method = config.method?.toUpperCase()
1001 const path = this.pathFromUrl(config.url, config.baseURL)
1002 return method ? `${method} ${path}` : path
1003 }
1004
1005 private pathFromUrl(rawUrl: string, baseURL?: string): string {
1006 try {
1007 return new URL(rawUrl, baseURL).pathname
1008 }
1009 catch {
1010 return rawUrl.split('?')[0] || rawUrl
1011 }
1012 }
1013
1014 private platformFromEndpoint(endpoint?: string): AccountType.WeChatOfficial | AccountType.WeChatChannels {
1015 if (endpoint?.includes('/channels/')
1016 || endpoint === 'POST /cgi-bin/token') {
1017 return AccountType.WeChatChannels
1018 }
1019 return AccountType.WeChatOfficial
1020 }
1021
1022 private codeFromEndpoint(endpoint?: string): ResponseCode {
1023 if (endpoint?.includes('/sns/oauth2/refresh_token')) {
1024 return ResponseCode.ChannelRefreshTokenFailed
1025 }
1026 if (endpoint?.includes('/sns/oauth2/access_token')
1027 || endpoint?.includes('/cgi-bin/token')) {
1028 return ResponseCode.ChannelAccessTokenFailed
1029 }
1030 if (endpoint?.includes('/cgi-bin/draft/add')
1031 || endpoint?.includes('/cgi-bin/freepublish/submit')
1032 || endpoint?.includes('/cgi-bin/media/uploadimg')
1033 || endpoint?.includes('/cgi-bin/material/add_material')
1034 || endpoint?.includes('/channels/medias/uploadvideo')
1035 || endpoint?.includes('/channels/medias/upload')
1036 || (endpoint?.includes('/channels/medias/publish') && !endpoint.includes('/channels/medias/publish/status'))) {
1037 return ResponseCode.ChannelPlatformMediaProcessingFailed
1038 }
1039 return ResponseCode.ChannelPlatformApiFailed
1040 }
1041 }
1042
1042 lines TYPESCRIPT