weflow-cli 1.5.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/LICENSE +21 -0
- package/README.md +149 -0
- package/bin/weflow-cli-electron.cjs +59 -0
- package/bin/weflow-cli.ts +2311 -0
- package/cli.cjs +3 -0
- package/dist/bin/weflow-cli.d.ts +3 -0
- package/dist/bin/weflow-cli.d.ts.map +1 -0
- package/dist/bin/weflow-cli.js +2270 -0
- package/dist/bin/weflow-cli.js.map +1 -0
- package/dist/mcp-server/index.d.ts +3 -0
- package/dist/mcp-server/index.d.ts.map +1 -0
- package/dist/mcp-server/index.js +690 -0
- package/dist/mcp-server/index.js.map +1 -0
- package/dist/src/core/dbPathService.d.ts +40 -0
- package/dist/src/core/dbPathService.d.ts.map +1 -0
- package/dist/src/core/dbPathService.js +324 -0
- package/dist/src/core/dbPathService.js.map +1 -0
- package/dist/src/core/keyService.d.ts +59 -0
- package/dist/src/core/keyService.d.ts.map +1 -0
- package/dist/src/core/keyService.js +368 -0
- package/dist/src/core/keyService.js.map +1 -0
- package/dist/src/core/ntCore.d.ts +62 -0
- package/dist/src/core/ntCore.d.ts.map +1 -0
- package/dist/src/core/ntCore.js +191 -0
- package/dist/src/core/ntCore.js.map +1 -0
- package/dist/src/core/sqlcipherCore.d.ts +119 -0
- package/dist/src/core/sqlcipherCore.d.ts.map +1 -0
- package/dist/src/core/sqlcipherCore.js +693 -0
- package/dist/src/core/sqlcipherCore.js.map +1 -0
- package/dist/src/core/wcdbCore.d.ts +738 -0
- package/dist/src/core/wcdbCore.d.ts.map +1 -0
- package/dist/src/core/wcdbCore.js +4545 -0
- package/dist/src/core/wcdbCore.js.map +1 -0
- package/dist/src/core/wechatClient.d.ts +23 -0
- package/dist/src/core/wechatClient.d.ts.map +1 -0
- package/dist/src/core/wechatClient.js +166 -0
- package/dist/src/core/wechatClient.js.map +1 -0
- package/dist/src/services/chatService.d.ts +25 -0
- package/dist/src/services/chatService.d.ts.map +1 -0
- package/dist/src/services/chatService.js +291 -0
- package/dist/src/services/chatService.js.map +1 -0
- package/dist/src/services/configService.d.ts +43 -0
- package/dist/src/services/configService.d.ts.map +1 -0
- package/dist/src/services/configService.js +156 -0
- package/dist/src/services/configService.js.map +1 -0
- package/dist/src/services/exportService.d.ts +30 -0
- package/dist/src/services/exportService.d.ts.map +1 -0
- package/dist/src/services/exportService.js +273 -0
- package/dist/src/services/exportService.js.map +1 -0
- package/dist/src/services/wechat-formatter.d.ts +69 -0
- package/dist/src/services/wechat-formatter.d.ts.map +1 -0
- package/dist/src/services/wechat-formatter.js +248 -0
- package/dist/src/services/wechat-formatter.js.map +1 -0
- package/dist/src/services/wechatMessageService.d.ts +28 -0
- package/dist/src/services/wechatMessageService.d.ts.map +1 -0
- package/dist/src/services/wechatMessageService.js +341 -0
- package/dist/src/services/wechatMessageService.js.map +1 -0
- package/dist/src/services/wereadService.d.ts +206 -0
- package/dist/src/services/wereadService.d.ts.map +1 -0
- package/dist/src/services/wereadService.js +145 -0
- package/dist/src/services/wereadService.js.map +1 -0
- package/dist/src/services/whitelistService.d.ts +20 -0
- package/dist/src/services/whitelistService.d.ts.map +1 -0
- package/dist/src/services/whitelistService.js +60 -0
- package/dist/src/services/whitelistService.js.map +1 -0
- package/dist/src/types.d.ts +140 -0
- package/dist/src/types.d.ts.map +1 -0
- package/dist/src/types.js +2 -0
- package/dist/src/types.js.map +1 -0
- package/dist/src/utils/errors.d.ts +25 -0
- package/dist/src/utils/errors.d.ts.map +1 -0
- package/dist/src/utils/errors.js +43 -0
- package/dist/src/utils/errors.js.map +1 -0
- package/dist/src/utils/pathUtils.d.ts +5 -0
- package/dist/src/utils/pathUtils.d.ts.map +1 -0
- package/dist/src/utils/pathUtils.js +16 -0
- package/dist/src/utils/pathUtils.js.map +1 -0
- package/dist/src/utils/pythonRunner.d.ts +27 -0
- package/dist/src/utils/pythonRunner.d.ts.map +1 -0
- package/dist/src/utils/pythonRunner.js +62 -0
- package/dist/src/utils/pythonRunner.js.map +1 -0
- package/dist/src/utils/talkerUtils.d.ts +14 -0
- package/dist/src/utils/talkerUtils.d.ts.map +1 -0
- package/dist/src/utils/talkerUtils.js +73 -0
- package/dist/src/utils/talkerUtils.js.map +1 -0
- package/mcp-server/index.ts +757 -0
- package/package.json +72 -0
- package/resources/key/win32/x64/wx_key.dll +0 -0
- package/resources/wcdb/win32/x64/SDL2.dll +0 -0
- package/resources/wcdb/win32/x64/WCDB.dll +0 -0
- package/resources/wcdb/win32/x64/msvcp140.dll +0 -0
- package/resources/wcdb/win32/x64/msvcp140_1.dll +0 -0
- package/resources/wcdb/win32/x64/vcruntime140.dll +0 -0
- package/resources/wcdb/win32/x64/vcruntime140_1.dll +0 -0
- package/resources/wcdb/win32/x64/wcdb_api.dll +0 -0
- package/scripts/_batch_link_sources.py +73 -0
- package/scripts/_utils.py +531 -0
- package/scripts/annual_report.py +796 -0
- package/scripts/auto_tag.py +128 -0
- package/scripts/biz_daily.py +896 -0
- package/scripts/chat_report.py +397 -0
- package/scripts/chat_stats.py +702 -0
- package/scripts/classify_daily.py +411 -0
- package/scripts/compile_wiki.py +258 -0
- package/scripts/create_reading_notes.py +370 -0
- package/scripts/enrich_backlinks.py +163 -0
- package/scripts/export_chat_html.py +766 -0
- package/scripts/extract_3x_key.py +86 -0
- package/scripts/extract_todos.py +404 -0
- package/scripts/fav_server.py +392 -0
- package/scripts/fix_topics.py +111 -0
- package/scripts/generate_ai_report.py +367 -0
- package/scripts/generate_html.py +1372 -0
- package/scripts/generate_review.py +170 -0
- package/scripts/mcp_bridge.py +398 -0
- package/scripts/nt_decrypt.py +562 -0
- package/scripts/pipeline.py +145 -0
- package/scripts/promote_all.py +310 -0
- package/scripts/promote_ideas.py +297 -0
- package/scripts/rag_chat.py +191 -0
- package/scripts/scan_decrypt_4x.py +329 -0
- package/scripts/semantic_search.py +431 -0
- package/scripts/sync_fav.py +146 -0
- package/scripts/sync_weread.py +192 -0
- package/scripts/vault_rag.py +139 -0
- package/scripts/vault_search.py +141 -0
- package/src/core/dbPathService.ts +348 -0
- package/src/core/keyService.ts +409 -0
- package/src/core/ntCore.ts +224 -0
- package/src/core/sqlcipherCore.ts +764 -0
- package/src/core/wcdbCore.ts +4568 -0
- package/src/core/wechatClient.ts +204 -0
- package/src/lz4.d.ts +10 -0
- package/src/services/chatService.ts +296 -0
- package/src/services/configService.ts +197 -0
- package/src/services/exportService.ts +305 -0
- package/src/services/wechat-formatter.ts +348 -0
- package/src/services/wechatMessageService.ts +383 -0
- package/src/services/wereadService.ts +308 -0
- package/src/services/whitelistService.ts +72 -0
- package/src/types.ts +141 -0
- package/src/utils/errors.ts +42 -0
- package/src/utils/pathUtils.ts +16 -0
- package/src/utils/pythonRunner.ts +81 -0
- package/src/utils/talkerUtils.ts +84 -0
|
@@ -0,0 +1,383 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 微信消息收发服务 — 通过 ilink API 桥接登录、收消息、发消息。
|
|
3
|
+
*
|
|
4
|
+
* 协议参考: AstrBot weixin_oc_adapter.py
|
|
5
|
+
*/
|
|
6
|
+
import crypto from 'crypto'
|
|
7
|
+
import path from 'path'
|
|
8
|
+
import { WechatClient } from '../core/wechatClient.js'
|
|
9
|
+
import { configService } from './configService.js'
|
|
10
|
+
import type { WechatOCConfig, WechatLoginSession, WechatInboundMessage, WechatMessageComponent } from '../types.js'
|
|
11
|
+
|
|
12
|
+
function sleep(ms: number): Promise<void> {
|
|
13
|
+
return new Promise(resolve => setTimeout(resolve, ms))
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function uuidHex(): string {
|
|
17
|
+
return crypto.randomUUID().replace(/-/g, '')
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export class WechatMessageService {
|
|
21
|
+
private client: WechatClient
|
|
22
|
+
private config: WechatOCConfig
|
|
23
|
+
private loginSession: WechatLoginSession | null = null
|
|
24
|
+
private shutdownFlag = false
|
|
25
|
+
private contextTokens: Map<string, string> = new Map()
|
|
26
|
+
private syncBuf: string
|
|
27
|
+
private messageCallbacks: Array<(msg: WechatInboundMessage) => void> = []
|
|
28
|
+
|
|
29
|
+
constructor(config: WechatOCConfig = {}) {
|
|
30
|
+
this.config = config
|
|
31
|
+
this.syncBuf = config.syncBuf || ''
|
|
32
|
+
this.client = new WechatClient({
|
|
33
|
+
baseUrl: config.baseUrl,
|
|
34
|
+
cdnBaseUrl: config.cdnBaseUrl,
|
|
35
|
+
apiTimeoutMs: config.apiTimeoutMs,
|
|
36
|
+
token: config.token,
|
|
37
|
+
})
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// ====== Login ======
|
|
41
|
+
|
|
42
|
+
async startLogin(): Promise<{ qrcodeUrl: string; qrcodeContent: string }> {
|
|
43
|
+
const data = await this.client.requestJson('GET', 'ilink/bot/get_bot_qrcode', {
|
|
44
|
+
params: { bot_type: this.config.botType || '3' },
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
const qrcode = data.qrcode as string
|
|
48
|
+
const qrcodeImgContent = data.qrcode_img_content as string
|
|
49
|
+
|
|
50
|
+
this.loginSession = {
|
|
51
|
+
sessionKey: uuidHex(),
|
|
52
|
+
qrcode,
|
|
53
|
+
qrcodeImgContent,
|
|
54
|
+
startedAt: Date.now(),
|
|
55
|
+
status: 'wait',
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
return { qrcodeUrl: qrcode, qrcodeContent: qrcodeImgContent }
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
async pollQrStatus(): Promise<WechatLoginSession> {
|
|
62
|
+
if (!this.loginSession) {
|
|
63
|
+
throw new Error('未开始登录流程,请先调用 startLogin()')
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const data = await this.client.requestJson('GET', 'ilink/bot/get_qrcode_status', {
|
|
67
|
+
params: { qrcode: this.loginSession.qrcode },
|
|
68
|
+
extraHeaders: { 'iLink-App-ClientVersion': '1' },
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
const status = data.status as string
|
|
72
|
+
this.loginSession.status = status as WechatLoginSession['status']
|
|
73
|
+
|
|
74
|
+
if (status === 'confirmed') {
|
|
75
|
+
this.loginSession.botToken = data.bot_token as string
|
|
76
|
+
this.loginSession.accountId = data.ilink_bot_id as string
|
|
77
|
+
this.loginSession.baseUrl = data.baseurl as string
|
|
78
|
+
this.loginSession.userId = data.ilink_user_id as string
|
|
79
|
+
|
|
80
|
+
// Persist token
|
|
81
|
+
if (this.loginSession.botToken) {
|
|
82
|
+
this.client.token = this.loginSession.botToken
|
|
83
|
+
configService.set('wechatOcToken', this.loginSession.botToken)
|
|
84
|
+
}
|
|
85
|
+
if (this.loginSession.accountId) {
|
|
86
|
+
configService.set('wechatOcAccountId', this.loginSession.accountId)
|
|
87
|
+
}
|
|
88
|
+
} else if (status === 'expired') {
|
|
89
|
+
// qrcode expired — caller should retry startLogin()
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
return this.loginSession
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
async waitForLogin(pollIntervalMs = 2000): Promise<WechatLoginSession> {
|
|
96
|
+
let expiredCount = 0
|
|
97
|
+
|
|
98
|
+
while (true) {
|
|
99
|
+
try {
|
|
100
|
+
const session = await this.pollQrStatus()
|
|
101
|
+
|
|
102
|
+
if (session.status === 'confirmed') {
|
|
103
|
+
return session
|
|
104
|
+
}
|
|
105
|
+
if (session.status === 'expired') {
|
|
106
|
+
expiredCount++
|
|
107
|
+
if (expiredCount >= 3) {
|
|
108
|
+
session.error = '二维码过期次数过多,请重新运行登录命令'
|
|
109
|
+
return session
|
|
110
|
+
}
|
|
111
|
+
await this.startLogin()
|
|
112
|
+
console.log('二维码已过期,已获取新二维码,请重新扫码')
|
|
113
|
+
}
|
|
114
|
+
} catch (e: any) {
|
|
115
|
+
// Timeout or network error — retry after interval
|
|
116
|
+
if (e.message?.includes('timeout')) {
|
|
117
|
+
// Expected: QR status poll times out, keep retrying
|
|
118
|
+
} else {
|
|
119
|
+
console.error(`轮询异常: ${e.message}`)
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
await sleep(pollIntervalMs)
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// ====== Message Polling ======
|
|
128
|
+
|
|
129
|
+
async startPolling(): Promise<void> {
|
|
130
|
+
while (!this.shutdownFlag) {
|
|
131
|
+
try {
|
|
132
|
+
const data = await this.client.requestJson('POST', 'ilink/bot/getupdates', {
|
|
133
|
+
payload: {
|
|
134
|
+
base_info: { channel_version: 'astrbot' },
|
|
135
|
+
get_updates_buf: this.syncBuf,
|
|
136
|
+
},
|
|
137
|
+
tokenRequired: true,
|
|
138
|
+
timeoutMs: 40_000, // Long-poll: server will hold until message arrives
|
|
139
|
+
})
|
|
140
|
+
|
|
141
|
+
// ret/errcode may be absent on empty response (normal for long-poll timeout)
|
|
142
|
+
if (data.ret != null && data.ret !== 0 || data.errcode != null && data.errcode !== 0) {
|
|
143
|
+
console.error(`getupdates error: ret=${data.ret} errcode=${data.errcode} errmsg=${data.errmsg || 'unknown'}`)
|
|
144
|
+
if (data.errcode === -1 || data.errcode === 401) {
|
|
145
|
+
console.error('Token may have expired, please login again')
|
|
146
|
+
}
|
|
147
|
+
await sleep(5000)
|
|
148
|
+
continue
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
if (data.get_updates_buf) {
|
|
152
|
+
this.syncBuf = data.get_updates_buf
|
|
153
|
+
configService.set('wechatOcSyncBuf', this.syncBuf as any)
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const msgs: any[] = data.msgs || []
|
|
157
|
+
for (const msg of msgs) {
|
|
158
|
+
if (this.shutdownFlag) return
|
|
159
|
+
const inbound = this.parseInboundMessage(msg)
|
|
160
|
+
if (inbound) {
|
|
161
|
+
for (const cb of this.messageCallbacks) {
|
|
162
|
+
try { cb(inbound) } catch {}
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
} catch (e: any) {
|
|
167
|
+
if (this.shutdownFlag) return
|
|
168
|
+
// Long-poll timeout is normal — server holds connection until message arrives
|
|
169
|
+
if (e.name === 'AbortError' || e.message?.includes('timeout')) {
|
|
170
|
+
// Restart poll immediately
|
|
171
|
+
continue
|
|
172
|
+
}
|
|
173
|
+
console.error(`Polling error: ${e.message}`)
|
|
174
|
+
await sleep(5000)
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
async stop(): Promise<void> {
|
|
180
|
+
this.shutdownFlag = true
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// ====== Send ======
|
|
184
|
+
|
|
185
|
+
async sendText(userId: string, text: string): Promise<boolean> {
|
|
186
|
+
if (!this.client.token) return false
|
|
187
|
+
|
|
188
|
+
const contextToken = this.contextTokens.get(userId)
|
|
189
|
+
if (!contextToken) return false
|
|
190
|
+
|
|
191
|
+
const result = await this.client.requestJson('POST', 'ilink/bot/sendmessage', {
|
|
192
|
+
payload: {
|
|
193
|
+
base_info: { channel_version: 'astrbot' },
|
|
194
|
+
msg: {
|
|
195
|
+
from_user_id: '',
|
|
196
|
+
to_user_id: userId,
|
|
197
|
+
client_id: uuidHex(),
|
|
198
|
+
message_type: 2,
|
|
199
|
+
message_state: 2,
|
|
200
|
+
context_token: contextToken,
|
|
201
|
+
item_list: [{ type: 1, text_item: { text } }],
|
|
202
|
+
},
|
|
203
|
+
},
|
|
204
|
+
tokenRequired: true,
|
|
205
|
+
})
|
|
206
|
+
|
|
207
|
+
return result.ret === 0 && result.errcode === 0
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
async sendMedia(
|
|
211
|
+
userId: string,
|
|
212
|
+
filePath: string,
|
|
213
|
+
mediaType: 'image' | 'video' | 'file',
|
|
214
|
+
): Promise<boolean> {
|
|
215
|
+
if (!this.client.token) return false
|
|
216
|
+
|
|
217
|
+
const contextToken = this.contextTokens.get(userId)
|
|
218
|
+
if (!contextToken) return false
|
|
219
|
+
|
|
220
|
+
const fs = await import('fs/promises')
|
|
221
|
+
const fileBuffer = await fs.readFile(filePath)
|
|
222
|
+
const fileName = path.basename(filePath)
|
|
223
|
+
const rawMD5 = crypto.createHash('md5').update(fileBuffer).digest('hex')
|
|
224
|
+
const rawSize = fileBuffer.length
|
|
225
|
+
|
|
226
|
+
const fileKey = uuidHex()
|
|
227
|
+
const aesKeyHex = crypto.randomBytes(16).toString('hex')
|
|
228
|
+
const cipherSize = WechatClient.aesPaddedSize(rawSize)
|
|
229
|
+
|
|
230
|
+
const typeMap = {
|
|
231
|
+
image: { uploadMediaType: 1, itemType: 2 },
|
|
232
|
+
video: { uploadMediaType: 2, itemType: 5 },
|
|
233
|
+
file: { uploadMediaType: 3, itemType: 4 },
|
|
234
|
+
}
|
|
235
|
+
const { uploadMediaType, itemType } = typeMap[mediaType]
|
|
236
|
+
|
|
237
|
+
// Get upload URL
|
|
238
|
+
const uploadResult = await this.client.requestJson('POST', 'ilink/bot/getuploadurl', {
|
|
239
|
+
payload: {
|
|
240
|
+
filekey: fileKey,
|
|
241
|
+
media_type: uploadMediaType,
|
|
242
|
+
to_user_id: userId,
|
|
243
|
+
rawsize: rawSize,
|
|
244
|
+
rawfilemd5: rawMD5,
|
|
245
|
+
filesize: cipherSize,
|
|
246
|
+
aeskey: aesKeyHex,
|
|
247
|
+
no_need_thumb: true,
|
|
248
|
+
base_info: { channel_version: 'astrbot' },
|
|
249
|
+
},
|
|
250
|
+
tokenRequired: true,
|
|
251
|
+
})
|
|
252
|
+
|
|
253
|
+
if (uploadResult.ret !== 0 || uploadResult.errcode !== 0) {
|
|
254
|
+
console.error(`getuploadurl error: ${uploadResult.errmsg}`)
|
|
255
|
+
return false
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// Upload to CDN
|
|
259
|
+
const encryptedParam = await this.client.uploadToCdn(
|
|
260
|
+
uploadResult.upload_full_url as string,
|
|
261
|
+
uploadResult.upload_param as string,
|
|
262
|
+
fileKey,
|
|
263
|
+
aesKeyHex,
|
|
264
|
+
fileBuffer,
|
|
265
|
+
)
|
|
266
|
+
|
|
267
|
+
// Build media item and send
|
|
268
|
+
const mediaPayload = {
|
|
269
|
+
encrypt_query_param: encryptedParam,
|
|
270
|
+
aes_key: Buffer.from(aesKeyHex, 'hex').toString('base64'),
|
|
271
|
+
encrypt_type: 1,
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
let itemList: any[]
|
|
275
|
+
if (mediaType === 'image') {
|
|
276
|
+
itemList = [{ type: 2, image_item: { media: mediaPayload, mid_size: rawSize, aeskey: aesKeyHex } }]
|
|
277
|
+
} else if (mediaType === 'file') {
|
|
278
|
+
itemList = [{ type: 4, file_item: { media: mediaPayload, file_name: fileName, len: rawSize } }]
|
|
279
|
+
} else {
|
|
280
|
+
itemList = [{ type: 5, video_item: { media: mediaPayload, video_size: rawSize } }]
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
const result = await this.client.requestJson('POST', 'ilink/bot/sendmessage', {
|
|
284
|
+
payload: {
|
|
285
|
+
base_info: { channel_version: 'astrbot' },
|
|
286
|
+
msg: {
|
|
287
|
+
from_user_id: '',
|
|
288
|
+
to_user_id: userId,
|
|
289
|
+
client_id: uuidHex(),
|
|
290
|
+
message_type: 2,
|
|
291
|
+
message_state: 2,
|
|
292
|
+
context_token: contextToken,
|
|
293
|
+
item_list: itemList,
|
|
294
|
+
},
|
|
295
|
+
},
|
|
296
|
+
tokenRequired: true,
|
|
297
|
+
})
|
|
298
|
+
|
|
299
|
+
return result.ret === 0 && result.errcode === 0
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
async sendImage(userId: string, imagePath: string): Promise<boolean> {
|
|
303
|
+
return this.sendMedia(userId, imagePath, 'image')
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
async sendFile(userId: string, filePath: string, _fileName?: string): Promise<boolean> {
|
|
307
|
+
return this.sendMedia(userId, filePath, 'file')
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
// ====== Callbacks ======
|
|
311
|
+
|
|
312
|
+
onMessage(callback: (msg: WechatInboundMessage) => void): void {
|
|
313
|
+
this.messageCallbacks.push(callback)
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
// ====== Status ======
|
|
317
|
+
|
|
318
|
+
isLoggedIn(): boolean {
|
|
319
|
+
return !!(this.client.token || this.config.token)
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
getAccountId(): string | null {
|
|
323
|
+
return this.config.accountId || null
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
// ====== Private ======
|
|
327
|
+
|
|
328
|
+
private parseInboundMessage(msg: any): WechatInboundMessage | null {
|
|
329
|
+
const fromUserId: string = msg.from_user_id || ''
|
|
330
|
+
if (!fromUserId) return null
|
|
331
|
+
|
|
332
|
+
// Store context token for future replies
|
|
333
|
+
if (msg.context_token) {
|
|
334
|
+
this.contextTokens.set(fromUserId, msg.context_token)
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
const components: WechatMessageComponent[] = []
|
|
338
|
+
const itemList: any[] = msg.item_list || []
|
|
339
|
+
|
|
340
|
+
for (const item of itemList) {
|
|
341
|
+
const itemType = item.type as number
|
|
342
|
+
if (itemType === 1 && item.text_item) {
|
|
343
|
+
components.push({ type: 'plain', text: item.text_item.text || '' })
|
|
344
|
+
} else if (itemType === 2 && item.image_item) {
|
|
345
|
+
components.push({ type: 'image', filePath: '' }) // image download deferred
|
|
346
|
+
} else if (itemType === 3 && item.voice_item) {
|
|
347
|
+
components.push({ type: 'record', filePath: '' })
|
|
348
|
+
} else if (itemType === 4 && item.file_item) {
|
|
349
|
+
components.push({ type: 'file', name: item.file_item.file_name || '', filePath: '' })
|
|
350
|
+
} else if (itemType === 5 && item.video_item) {
|
|
351
|
+
components.push({ type: 'video', filePath: '' })
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
// Determine message kind
|
|
356
|
+
let messageKind: WechatInboundMessage['messageKind'] = 'unknown'
|
|
357
|
+
if (itemList.length === 1) {
|
|
358
|
+
const t = itemList[0].type
|
|
359
|
+
if (t === 1) messageKind = 'text'
|
|
360
|
+
else if (t === 2) messageKind = 'image'
|
|
361
|
+
else if (t === 3) messageKind = 'voice'
|
|
362
|
+
else if (t === 4) messageKind = 'file'
|
|
363
|
+
else if (t === 5) messageKind = 'video'
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
const textComponents = components.filter(c => c.type === 'plain')
|
|
367
|
+
const messageStr = textComponents.map(c => (c as { type: 'plain'; text: string }).text).join('')
|
|
368
|
+
|
|
369
|
+
return {
|
|
370
|
+
messageId: msg.client_id || uuidHex(),
|
|
371
|
+
fromUserId,
|
|
372
|
+
senderNickname: msg.from_user_id || '',
|
|
373
|
+
timestamp: Math.floor(Date.now() / 1000),
|
|
374
|
+
timestampMs: Date.now(),
|
|
375
|
+
components,
|
|
376
|
+
messageStr,
|
|
377
|
+
messageKind,
|
|
378
|
+
rawMessage: msg,
|
|
379
|
+
isReply: !!(msg.ref_msg),
|
|
380
|
+
quotedText: msg.ref_msg?.text || undefined,
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
}
|
|
@@ -0,0 +1,308 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 微信读书 Agent API Gateway 封装
|
|
3
|
+
*
|
|
4
|
+
* 接口文档: ~/.claude/skills/weread-skills/
|
|
5
|
+
* Gateway: https://i.weread.qq.com/api/agent/gateway
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import https from 'node:https'
|
|
9
|
+
|
|
10
|
+
const GATEWAY = 'https://i.weread.qq.com/api/agent/gateway'
|
|
11
|
+
const SKILL_VERSION = '1.0.3'
|
|
12
|
+
|
|
13
|
+
// weread.qq.com 服务端不支持 TLS 1.3,需要强制使用 TLS 1.2
|
|
14
|
+
const httpsAgent = new https.Agent({ maxVersion: 'TLSv1.2' })
|
|
15
|
+
|
|
16
|
+
export interface WereadResult<T = any> {
|
|
17
|
+
ok: boolean
|
|
18
|
+
data?: T
|
|
19
|
+
error?: string
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// ---- shelf ----
|
|
23
|
+
|
|
24
|
+
export interface ShelfBook {
|
|
25
|
+
bookId: string
|
|
26
|
+
title: string
|
|
27
|
+
author: string
|
|
28
|
+
cover: string
|
|
29
|
+
category: string
|
|
30
|
+
readUpdateTime: number
|
|
31
|
+
finishReading: number
|
|
32
|
+
updateTime: number
|
|
33
|
+
secret: number
|
|
34
|
+
isTop: boolean
|
|
35
|
+
payType: number
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface ShelfData {
|
|
39
|
+
books: ShelfBook[]
|
|
40
|
+
albums: any[]
|
|
41
|
+
mp: any
|
|
42
|
+
archive: { name: string; bookIds: string[]; albumIds: string[] }[]
|
|
43
|
+
bookCount: number
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// ---- readdata ----
|
|
47
|
+
|
|
48
|
+
export interface ReadDetail {
|
|
49
|
+
baseTime: number
|
|
50
|
+
readTimes: Record<string, number>
|
|
51
|
+
readDays: number
|
|
52
|
+
totalReadTime: number
|
|
53
|
+
dayAverageReadTime: number
|
|
54
|
+
compare?: number
|
|
55
|
+
readLongest: { book?: any; albumInfo?: any; readTime: number; tags?: string[] }[]
|
|
56
|
+
readStat: { stat: string; counts: string; scheme?: string }[]
|
|
57
|
+
preferCategory: { categoryId: number; categoryTitle: string; readingTime: number; readingCount: number; val: number }[]
|
|
58
|
+
preferCategoryWord?: string
|
|
59
|
+
preferTime: number[]
|
|
60
|
+
preferTimeWord?: string
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// ---- notes ----
|
|
64
|
+
|
|
65
|
+
export interface NotebookItem {
|
|
66
|
+
bookId: string
|
|
67
|
+
title: string
|
|
68
|
+
author: string
|
|
69
|
+
cover: string
|
|
70
|
+
highlightCount: number
|
|
71
|
+
noteCount: number
|
|
72
|
+
bookmarkCount: number
|
|
73
|
+
updateTime: number
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export interface NoteDetail {
|
|
77
|
+
bookId: string
|
|
78
|
+
chapterUid: number
|
|
79
|
+
chapterTitle: string
|
|
80
|
+
markText: string
|
|
81
|
+
content: string
|
|
82
|
+
range: string
|
|
83
|
+
createTime: number
|
|
84
|
+
type: number // 0=划线, 1=想法
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// ---- search ----
|
|
88
|
+
|
|
89
|
+
export interface SearchResult {
|
|
90
|
+
bookId: string
|
|
91
|
+
title: string
|
|
92
|
+
author: string
|
|
93
|
+
cover: string
|
|
94
|
+
rating: number
|
|
95
|
+
ratingCount: number
|
|
96
|
+
category: string
|
|
97
|
+
wordCount: string
|
|
98
|
+
intro: string
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// ---- book ----
|
|
102
|
+
|
|
103
|
+
export interface BookInfo {
|
|
104
|
+
bookId: string
|
|
105
|
+
title: string
|
|
106
|
+
author: string
|
|
107
|
+
cover: string
|
|
108
|
+
rating: number
|
|
109
|
+
ratingCount: number
|
|
110
|
+
category: string
|
|
111
|
+
wordCount: string
|
|
112
|
+
intro: string
|
|
113
|
+
publisher: string
|
|
114
|
+
isbn: string
|
|
115
|
+
totalWords: string
|
|
116
|
+
format: string
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export interface ChapterInfo {
|
|
120
|
+
bookId: string
|
|
121
|
+
title: string
|
|
122
|
+
chapters: { chapterUid: number; title: string; level: number; wordCount: number }[]
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// ---- review ----
|
|
126
|
+
|
|
127
|
+
export interface BookReview {
|
|
128
|
+
reviewId: string
|
|
129
|
+
content: string
|
|
130
|
+
rating: number
|
|
131
|
+
createTime: number
|
|
132
|
+
user: { name: string; avatar: string }
|
|
133
|
+
likeCount: number
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// ---- discover ----
|
|
137
|
+
|
|
138
|
+
export interface RecommendBook {
|
|
139
|
+
bookId: string
|
|
140
|
+
title: string
|
|
141
|
+
author: string
|
|
142
|
+
cover: string
|
|
143
|
+
rating: number
|
|
144
|
+
intro: string
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// ---- profile ----
|
|
148
|
+
|
|
149
|
+
export interface UserProfile {
|
|
150
|
+
vid: string
|
|
151
|
+
name: string
|
|
152
|
+
avatar: string
|
|
153
|
+
gender: number
|
|
154
|
+
totalReadTime: number
|
|
155
|
+
totalReadDays: number
|
|
156
|
+
totalBooks: number
|
|
157
|
+
totalFinished: number
|
|
158
|
+
totalNotes: number
|
|
159
|
+
totalHighlights: number
|
|
160
|
+
totalReviews: number
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
export class WereadService {
|
|
165
|
+
private apiKey: string
|
|
166
|
+
|
|
167
|
+
constructor(apiKey?: string) {
|
|
168
|
+
this.apiKey = apiKey || process.env.WEREAD_API_KEY || ''
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
private async call<T>(apiName: string, params: Record<string, any> = {}): Promise<WereadResult<T>> {
|
|
172
|
+
if (!this.apiKey) {
|
|
173
|
+
return { ok: false, error: '未设置 WEREAD_API_KEY,请设置环境变量或在 CLI 中配置' }
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
try {
|
|
177
|
+
const body = JSON.stringify({ api_name: apiName, skill_version: SKILL_VERSION, ...params })
|
|
178
|
+
const json = await new Promise<any>((resolve, reject) => {
|
|
179
|
+
const url = new URL(GATEWAY)
|
|
180
|
+
const req = https.request({
|
|
181
|
+
hostname: url.hostname,
|
|
182
|
+
path: url.pathname,
|
|
183
|
+
method: 'POST',
|
|
184
|
+
agent: httpsAgent,
|
|
185
|
+
headers: {
|
|
186
|
+
'Authorization': `Bearer ${this.apiKey}`,
|
|
187
|
+
'Content-Type': 'application/json',
|
|
188
|
+
'Content-Length': Buffer.byteLength(body),
|
|
189
|
+
},
|
|
190
|
+
}, (res) => {
|
|
191
|
+
let data = ''
|
|
192
|
+
res.on('data', (chunk: Buffer) => data += chunk.toString())
|
|
193
|
+
res.on('end', () => {
|
|
194
|
+
try { resolve(JSON.parse(data)) }
|
|
195
|
+
catch { reject(new Error(`Invalid JSON response: ${data.slice(0, 200)}`)) }
|
|
196
|
+
})
|
|
197
|
+
})
|
|
198
|
+
req.on('error', reject)
|
|
199
|
+
req.write(body)
|
|
200
|
+
req.end()
|
|
201
|
+
})
|
|
202
|
+
|
|
203
|
+
if (json.errcode && json.errcode !== 0) {
|
|
204
|
+
return { ok: false, error: `API 错误: ${json.errmsg || json.errcode}` }
|
|
205
|
+
}
|
|
206
|
+
return { ok: true, data: json as T }
|
|
207
|
+
} catch (e: any) {
|
|
208
|
+
return { ok: false, error: `请求失败: ${e.message}` }
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/** 书架同步 */
|
|
213
|
+
async shelf(): Promise<WereadResult<ShelfData>> {
|
|
214
|
+
return this.call<ShelfData>('/shelf/sync')
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/** 阅读统计: mode = weekly | monthly | annually | overall */
|
|
218
|
+
async readData(mode: string = 'monthly', baseTime?: number): Promise<WereadResult<ReadDetail>> {
|
|
219
|
+
const params: any = { mode }
|
|
220
|
+
if (baseTime !== undefined) params.baseTime = baseTime
|
|
221
|
+
return this.call<ReadDetail>('/readdata/detail', params)
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/** 笔记列表: 获取用户的笔记本列表 */
|
|
225
|
+
async notebooks(count: number = 100, lastSort?: number): Promise<WereadResult<{ books: NotebookItem[]; synckey: number }>> {
|
|
226
|
+
const params: any = { count }
|
|
227
|
+
if (lastSort !== undefined) params.lastSort = lastSort
|
|
228
|
+
return this.call('/user/notebooks', params)
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/** 某本书的划线 */
|
|
232
|
+
async bookmarks(bookId: string, count: number = 50): Promise<WereadResult<{ updated: NoteDetail[]; removed: any[]; synckey: number }>> {
|
|
233
|
+
return this.call('/book/bookmarklist', { bookId, count })
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/** 某本书的热门划线 */
|
|
237
|
+
async bestBookmarks(bookId: string, count: number = 20): Promise<WereadResult<{ chapters: any[] }>> {
|
|
238
|
+
return this.call('/book/bestbookmarks', { bookId, count })
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/** 搜索书籍 */
|
|
242
|
+
async search(keyword: string, count: number = 10, scope: string = 'all'): Promise<WereadResult<{ books: SearchResult[] }>> {
|
|
243
|
+
return this.call('/store/search', { keyword, count, scope })
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/** 书籍详情 */
|
|
247
|
+
async bookInfo(bookId: string): Promise<WereadResult<BookInfo>> {
|
|
248
|
+
return this.call<BookInfo>('/book/info', { bookId })
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/** 章节目录 */
|
|
252
|
+
async chapterInfo(bookId: string): Promise<WereadResult<ChapterInfo>> {
|
|
253
|
+
return this.call<ChapterInfo>('/book/chapterinfo', { bookId })
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/** 阅读进度 */
|
|
257
|
+
async getProgress(bookId: string): Promise<WereadResult<{ chapterUid: number; chapterTitle: string; progress: number }>> {
|
|
258
|
+
return this.call('/book/getprogress', { bookId })
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/** 书评 */
|
|
262
|
+
async reviews(bookId: string, count: number = 20, sort: string = 'hot'): Promise<WereadResult<{ reviews: BookReview[] }>> {
|
|
263
|
+
return this.call('/book/readreviews', { bookId, count, sort })
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/** 个性化推荐 */
|
|
267
|
+
async recommend(count: number = 10): Promise<WereadResult<{ books: RecommendBook[] }>> {
|
|
268
|
+
return this.call('/book/recommend', { count })
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/** 相似推荐 */
|
|
272
|
+
async similar(bookId: string, count: number = 5): Promise<WereadResult<{ books: RecommendBook[] }>> {
|
|
273
|
+
return this.call('/book/recommend', { bookId, count, type: 'similar' })
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/** 个人中心(聚合多个接口) */
|
|
277
|
+
async profile(): Promise<WereadResult<UserProfile>> {
|
|
278
|
+
const [shelfRes, readRes] = await Promise.all([
|
|
279
|
+
this.shelf(),
|
|
280
|
+
this.readData('overall'),
|
|
281
|
+
])
|
|
282
|
+
|
|
283
|
+
if (!shelfRes.ok) return { ok: false, error: shelfRes.error }
|
|
284
|
+
if (!readRes.ok) return { ok: false, error: readRes.error }
|
|
285
|
+
|
|
286
|
+
const shelf = shelfRes.data!
|
|
287
|
+
const read = readRes.data!
|
|
288
|
+
|
|
289
|
+
return {
|
|
290
|
+
ok: true,
|
|
291
|
+
data: {
|
|
292
|
+
vid: '',
|
|
293
|
+
name: '',
|
|
294
|
+
avatar: '',
|
|
295
|
+
gender: 0,
|
|
296
|
+
totalReadTime: read.totalReadTime || 0,
|
|
297
|
+
totalReadDays: read.readDays || 0,
|
|
298
|
+
totalBooks: shelf.books?.length || 0,
|
|
299
|
+
totalFinished: shelf.books?.filter(b => b.finishReading === 1).length || 0,
|
|
300
|
+
totalNotes: 0,
|
|
301
|
+
totalHighlights: 0,
|
|
302
|
+
totalReviews: 0,
|
|
303
|
+
},
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
export const wereadService = new WereadService()
|