twikoo-func 1.7.14 → 1.7.16
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/index.js +41 -2
- package/package.json +7 -4
- package/utils/cap.js +311 -0
- package/utils/image.js +89 -39
- package/utils/index.js +17 -8
- package/utils/lib.js +0 -8
- package/utils/notify.js +21 -9
- package/utils/spam.js +18 -23
package/index.js
CHANGED
|
@@ -38,6 +38,13 @@ const {
|
|
|
38
38
|
checkCommentOwnership,
|
|
39
39
|
isValidEmail
|
|
40
40
|
} = require('./utils')
|
|
41
|
+
const {
|
|
42
|
+
createCap,
|
|
43
|
+
tcbStorage,
|
|
44
|
+
createChallenge,
|
|
45
|
+
redeemChallenge,
|
|
46
|
+
isBuiltinCap
|
|
47
|
+
} = require('./utils/cap')
|
|
41
48
|
const {
|
|
42
49
|
jsonParse,
|
|
43
50
|
commentImportValine,
|
|
@@ -151,6 +158,12 @@ exports.main = async (event, context) => {
|
|
|
151
158
|
case 'COMMENT_DELETE_FOR_USER':
|
|
152
159
|
res = await commentDeleteForUser(event)
|
|
153
160
|
break
|
|
161
|
+
case 'CAP_CHALLENGE': // embedded Cap.js
|
|
162
|
+
res = await capChallenge()
|
|
163
|
+
break
|
|
164
|
+
case 'CAP_REDEEM':
|
|
165
|
+
res = await capRedeem(event)
|
|
166
|
+
break
|
|
154
167
|
default:
|
|
155
168
|
if (event.event) {
|
|
156
169
|
res.code = RES_CODE.EVENT_NOT_EXIST
|
|
@@ -760,6 +773,14 @@ async function checkCaptcha (comment) {
|
|
|
760
773
|
geeTestPassToken: comment.geeTestPassToken,
|
|
761
774
|
geeTestGenTime: comment.geeTestGenTime
|
|
762
775
|
})
|
|
776
|
+
} else if (provider === 'Cap' && isBuiltinCap(config)) {
|
|
777
|
+
if (!comment.capToken) {
|
|
778
|
+
throw new Error('验证码 token 缺失,请刷新页面重试')
|
|
779
|
+
}
|
|
780
|
+
await checkCapCaptcha({
|
|
781
|
+
capToken: comment.capToken,
|
|
782
|
+
cap: createCap(tcbStorage(db))
|
|
783
|
+
})
|
|
763
784
|
} else if (provider === 'Cap' && config.CAP_API_ENDPOINT && config.CAP_SECRET_KEY) {
|
|
764
785
|
if (!comment.capToken) {
|
|
765
786
|
throw new Error('验证码 token 缺失,请刷新页面重试')
|
|
@@ -770,7 +791,7 @@ async function checkCaptcha (comment) {
|
|
|
770
791
|
capApiEndpoint: config.CAP_API_ENDPOINT
|
|
771
792
|
})
|
|
772
793
|
} else if (provider === 'Cap') {
|
|
773
|
-
throw new Error('Cap
|
|
794
|
+
throw new Error('Cap 验证码配置不完整:内嵌模式无需额外配置,外部模式需填写 CAP_API_ENDPOINT 与 CAP_SECRET_KEY')
|
|
774
795
|
} else if (provider) {
|
|
775
796
|
throw new Error(`不支持的验证码类型: ${provider}`)
|
|
776
797
|
}
|
|
@@ -1027,9 +1048,27 @@ function isRecursion (context) {
|
|
|
1027
1048
|
return envObj.TCB_SOURCE.substr(-3, 3) === 'scf'
|
|
1028
1049
|
}
|
|
1029
1050
|
|
|
1051
|
+
async function capChallenge () {
|
|
1052
|
+
if (!isBuiltinCap(config)) {
|
|
1053
|
+
return { code: RES_CODE.FAIL, message: '内嵌 Cap 未启用' }
|
|
1054
|
+
}
|
|
1055
|
+
const cap = createCap(tcbStorage(db))
|
|
1056
|
+
const data = await createChallenge(cap)
|
|
1057
|
+
return { code: RES_CODE.SUCCESS, ...data }
|
|
1058
|
+
}
|
|
1059
|
+
|
|
1060
|
+
async function capRedeem (event) {
|
|
1061
|
+
if (!isBuiltinCap(config)) {
|
|
1062
|
+
return { code: RES_CODE.FAIL, message: '内嵌 Cap 未启用' }
|
|
1063
|
+
}
|
|
1064
|
+
const cap = createCap(tcbStorage(db))
|
|
1065
|
+
const data = await redeemChallenge(cap, event)
|
|
1066
|
+
return { code: RES_CODE.SUCCESS, ...data }
|
|
1067
|
+
}
|
|
1068
|
+
|
|
1030
1069
|
// 建立数据库 collections
|
|
1031
1070
|
async function createCollections () {
|
|
1032
|
-
const collections = ['comment', 'config', 'counter']
|
|
1071
|
+
const collections = ['comment', 'config', 'counter', 'cap_challenges', 'cap_tokens']
|
|
1033
1072
|
const res = {}
|
|
1034
1073
|
for (const collection of collections) {
|
|
1035
1074
|
try {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "twikoo-func",
|
|
3
|
-
"version": "1.7.
|
|
3
|
+
"version": "1.7.16",
|
|
4
4
|
"description": "A simple comment system.",
|
|
5
5
|
"author": "imaegoo <hello@imaegoo.com> (https://github.com/imaegoo)",
|
|
6
6
|
"license": "MIT",
|
|
@@ -11,6 +11,10 @@
|
|
|
11
11
|
},
|
|
12
12
|
"homepage": "https://twikoo.js.org",
|
|
13
13
|
"dependencies": {
|
|
14
|
+
"@xsai/generate-text": "0.2.2",
|
|
15
|
+
"@xsai/shared": "0.2.2",
|
|
16
|
+
"@xsai/shared-chat": "0.2.2",
|
|
17
|
+
"@cap.js/server": "^4.0.5",
|
|
14
18
|
"@cloudbase/manager-node": "^3.9.0",
|
|
15
19
|
"@cloudbase/node-sdk": "^2.5.0",
|
|
16
20
|
"@imaegoo/node-ip2region": "^2.1.1",
|
|
@@ -20,12 +24,11 @@
|
|
|
20
24
|
"bowser": "^2.11.0",
|
|
21
25
|
"cheerio": "1.0.0-rc.5",
|
|
22
26
|
"crypto-js": "^4.0.0",
|
|
23
|
-
"dompurify": "^2.
|
|
27
|
+
"dompurify": "^2.5.9",
|
|
24
28
|
"form-data": "^4.0.0",
|
|
25
29
|
"jsdom": "^16.4.0",
|
|
26
30
|
"marked": "^4.0.12",
|
|
27
|
-
"nodemailer": "^
|
|
28
|
-
"openai": "^6.45.0",
|
|
31
|
+
"nodemailer": "^9.0.5",
|
|
29
32
|
"pushoo": "latest",
|
|
30
33
|
"tencentcloud-sdk-nodejs": "^4.0.65",
|
|
31
34
|
"xml2js": "^0.6.0"
|
package/utils/cap.js
ADDED
|
@@ -0,0 +1,311 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Embedded Cap.js (https://capjs.js.org) — no external Cap Standalone required.
|
|
3
|
+
* Storage adapters follow the same hooks as @cap.js/server / ink-battles.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
const Cap = require('@cap.js/server')
|
|
7
|
+
const logger = require('./logger')
|
|
8
|
+
|
|
9
|
+
const CHALLENGE_OPTS = {
|
|
10
|
+
challengeCount: 50,
|
|
11
|
+
challengeSize: 32,
|
|
12
|
+
challengeDifficulty: 4,
|
|
13
|
+
expiresMs: 600000 // 10 min
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function memoryStorage () {
|
|
17
|
+
const challenges = new Map()
|
|
18
|
+
const tokens = new Map()
|
|
19
|
+
return {
|
|
20
|
+
challenges: {
|
|
21
|
+
store: async (token, data) => { challenges.set(token, data) },
|
|
22
|
+
read: async (token) => challenges.get(token) || null,
|
|
23
|
+
delete: async (token) => { challenges.delete(token) },
|
|
24
|
+
deleteExpired: async () => {
|
|
25
|
+
const now = Date.now()
|
|
26
|
+
for (const [k, v] of challenges) {
|
|
27
|
+
if (!v || v.expires < now) challenges.delete(k)
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
},
|
|
31
|
+
tokens: {
|
|
32
|
+
store: async (key, expires) => { tokens.set(key, expires) },
|
|
33
|
+
get: async (key) => {
|
|
34
|
+
const exp = tokens.get(key)
|
|
35
|
+
if (!exp) return null
|
|
36
|
+
if (exp < Date.now()) {
|
|
37
|
+
tokens.delete(key)
|
|
38
|
+
return null
|
|
39
|
+
}
|
|
40
|
+
return exp
|
|
41
|
+
},
|
|
42
|
+
delete: async (key) => { tokens.delete(key) },
|
|
43
|
+
deleteExpired: async () => {
|
|
44
|
+
const now = Date.now()
|
|
45
|
+
for (const [k, v] of tokens) {
|
|
46
|
+
if (v < now) tokens.delete(k)
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** MongoDB (native driver) — Vercel / self-hosted mongo */
|
|
54
|
+
function mongoStorage (db) {
|
|
55
|
+
const challenges = () => db.collection('cap_challenges')
|
|
56
|
+
const tokens = () => db.collection('cap_tokens')
|
|
57
|
+
let indexed = false
|
|
58
|
+
const ensureIndex = () => {
|
|
59
|
+
if (indexed) return
|
|
60
|
+
indexed = true
|
|
61
|
+
challenges().createIndex({ expiresAt: 1 }, { expireAfterSeconds: 0 }).catch(() => {})
|
|
62
|
+
tokens().createIndex({ expiresAt: 1 }, { expireAfterSeconds: 0 }).catch(() => {})
|
|
63
|
+
}
|
|
64
|
+
return {
|
|
65
|
+
challenges: {
|
|
66
|
+
store: async (token, data) => {
|
|
67
|
+
ensureIndex()
|
|
68
|
+
await challenges().updateOne(
|
|
69
|
+
{ token },
|
|
70
|
+
{ $set: { token, challenge: data.challenge, expiresAt: new Date(data.expires) } },
|
|
71
|
+
{ upsert: true }
|
|
72
|
+
)
|
|
73
|
+
},
|
|
74
|
+
read: async (token) => {
|
|
75
|
+
const doc = await challenges().findOne({ token, expiresAt: { $gt: new Date() } })
|
|
76
|
+
return doc ? { challenge: doc.challenge, expires: doc.expiresAt.getTime() } : null
|
|
77
|
+
},
|
|
78
|
+
delete: async (token) => { await challenges().deleteOne({ token }) },
|
|
79
|
+
deleteExpired: async () => { await challenges().deleteMany({ expiresAt: { $lte: new Date() } }) }
|
|
80
|
+
},
|
|
81
|
+
tokens: {
|
|
82
|
+
store: async (key, expires) => {
|
|
83
|
+
ensureIndex()
|
|
84
|
+
await tokens().updateOne(
|
|
85
|
+
{ key },
|
|
86
|
+
{ $set: { key, expiresAt: new Date(expires) } },
|
|
87
|
+
{ upsert: true }
|
|
88
|
+
)
|
|
89
|
+
},
|
|
90
|
+
get: async (key) => {
|
|
91
|
+
const doc = await tokens().findOne({ key, expiresAt: { $gt: new Date() } })
|
|
92
|
+
return doc ? doc.expiresAt.getTime() : null
|
|
93
|
+
},
|
|
94
|
+
delete: async (key) => { await tokens().deleteOne({ key }) },
|
|
95
|
+
deleteExpired: async () => { await tokens().deleteMany({ expiresAt: { $lte: new Date() } }) }
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** LokiJS — self-hosted default */
|
|
101
|
+
function lokiStorage (db) {
|
|
102
|
+
const getCol = (name) => {
|
|
103
|
+
let col = db.getCollection(name)
|
|
104
|
+
if (!col) col = db.addCollection(name, { unique: ['key'] })
|
|
105
|
+
return col
|
|
106
|
+
}
|
|
107
|
+
// challenges use token field; tokens use key field
|
|
108
|
+
const getChallengeCol = () => {
|
|
109
|
+
let col = db.getCollection('cap_challenges')
|
|
110
|
+
if (!col) col = db.addCollection('cap_challenges', { unique: ['token'] })
|
|
111
|
+
return col
|
|
112
|
+
}
|
|
113
|
+
const getTokenCol = () => getCol('cap_tokens')
|
|
114
|
+
return {
|
|
115
|
+
challenges: {
|
|
116
|
+
store: async (token, data) => {
|
|
117
|
+
const col = getChallengeCol()
|
|
118
|
+
const existing = col.findOne({ token })
|
|
119
|
+
const row = { token, challenge: data.challenge, expires: data.expires }
|
|
120
|
+
if (existing) {
|
|
121
|
+
Object.assign(existing, row)
|
|
122
|
+
col.update(existing)
|
|
123
|
+
} else col.insert(row)
|
|
124
|
+
},
|
|
125
|
+
read: async (token) => {
|
|
126
|
+
const doc = getChallengeCol().findOne({ token })
|
|
127
|
+
if (!doc || doc.expires < Date.now()) return null
|
|
128
|
+
return { challenge: doc.challenge, expires: doc.expires }
|
|
129
|
+
},
|
|
130
|
+
delete: async (token) => { getChallengeCol().findAndRemove({ token }) },
|
|
131
|
+
deleteExpired: async () => {
|
|
132
|
+
getChallengeCol().findAndRemove({ expires: { $lte: Date.now() } })
|
|
133
|
+
}
|
|
134
|
+
},
|
|
135
|
+
tokens: {
|
|
136
|
+
store: async (key, expires) => {
|
|
137
|
+
const col = getTokenCol()
|
|
138
|
+
const existing = col.findOne({ key })
|
|
139
|
+
const row = { key, expires }
|
|
140
|
+
if (existing) {
|
|
141
|
+
Object.assign(existing, row)
|
|
142
|
+
col.update(existing)
|
|
143
|
+
} else col.insert(row)
|
|
144
|
+
},
|
|
145
|
+
get: async (key) => {
|
|
146
|
+
const doc = getTokenCol().findOne({ key })
|
|
147
|
+
if (!doc || doc.expires < Date.now()) return null
|
|
148
|
+
return doc.expires
|
|
149
|
+
},
|
|
150
|
+
delete: async (key) => { getTokenCol().findAndRemove({ key }) },
|
|
151
|
+
deleteExpired: async () => {
|
|
152
|
+
getTokenCol().findAndRemove({ expires: { $lte: Date.now() } })
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** CloudBase / TCB database */
|
|
159
|
+
function tcbStorage (db) {
|
|
160
|
+
const _ = db.command
|
|
161
|
+
return {
|
|
162
|
+
challenges: {
|
|
163
|
+
store: async (token, data) => {
|
|
164
|
+
const col = db.collection('cap_challenges')
|
|
165
|
+
try {
|
|
166
|
+
await col.doc(token).set({
|
|
167
|
+
token,
|
|
168
|
+
challenge: data.challenge,
|
|
169
|
+
expires: data.expires
|
|
170
|
+
})
|
|
171
|
+
} catch (e) {
|
|
172
|
+
// doc may exist
|
|
173
|
+
await col.doc(token).update({
|
|
174
|
+
challenge: data.challenge,
|
|
175
|
+
expires: data.expires
|
|
176
|
+
})
|
|
177
|
+
}
|
|
178
|
+
},
|
|
179
|
+
read: async (token) => {
|
|
180
|
+
try {
|
|
181
|
+
const res = await db.collection('cap_challenges').doc(token).get()
|
|
182
|
+
const doc = res.data && res.data[0]
|
|
183
|
+
if (!doc || doc.expires < Date.now()) return null
|
|
184
|
+
return { challenge: doc.challenge, expires: doc.expires }
|
|
185
|
+
} catch (e) {
|
|
186
|
+
return null
|
|
187
|
+
}
|
|
188
|
+
},
|
|
189
|
+
delete: async (token) => {
|
|
190
|
+
try { await db.collection('cap_challenges').doc(token).remove() } catch (e) {}
|
|
191
|
+
},
|
|
192
|
+
deleteExpired: async () => {
|
|
193
|
+
try {
|
|
194
|
+
await db.collection('cap_challenges').where({ expires: _.lte(Date.now()) }).remove()
|
|
195
|
+
} catch (e) {}
|
|
196
|
+
}
|
|
197
|
+
},
|
|
198
|
+
tokens: {
|
|
199
|
+
store: async (key, expires) => {
|
|
200
|
+
// CloudBase doc id cannot contain ':'
|
|
201
|
+
const id = key.replace(/:/g, '_')
|
|
202
|
+
try {
|
|
203
|
+
await db.collection('cap_tokens').doc(id).set({ key, expires })
|
|
204
|
+
} catch (e) {
|
|
205
|
+
await db.collection('cap_tokens').doc(id).update({ key, expires })
|
|
206
|
+
}
|
|
207
|
+
},
|
|
208
|
+
get: async (key) => {
|
|
209
|
+
try {
|
|
210
|
+
const id = key.replace(/:/g, '_')
|
|
211
|
+
const res = await db.collection('cap_tokens').doc(id).get()
|
|
212
|
+
const doc = res.data && res.data[0]
|
|
213
|
+
if (!doc || doc.expires < Date.now()) return null
|
|
214
|
+
return doc.expires
|
|
215
|
+
} catch (e) {
|
|
216
|
+
return null
|
|
217
|
+
}
|
|
218
|
+
},
|
|
219
|
+
delete: async (key) => {
|
|
220
|
+
try {
|
|
221
|
+
const id = key.replace(/:/g, '_')
|
|
222
|
+
await db.collection('cap_tokens').doc(id).remove()
|
|
223
|
+
} catch (e) {}
|
|
224
|
+
},
|
|
225
|
+
deleteExpired: async () => {
|
|
226
|
+
try {
|
|
227
|
+
await db.collection('cap_tokens').where({ expires: _.lte(Date.now()) }).remove()
|
|
228
|
+
} catch (e) {}
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* EdgeOne Blob-style store: expects { get(key), set(key, value), del(key) }
|
|
236
|
+
* value is JSON-serializable.
|
|
237
|
+
*/
|
|
238
|
+
function kvStorage (kv) {
|
|
239
|
+
return {
|
|
240
|
+
challenges: {
|
|
241
|
+
store: async (token, data) => { await kv.set(`cap:c:${token}`, data) },
|
|
242
|
+
read: async (token) => {
|
|
243
|
+
const data = await kv.get(`cap:c:${token}`)
|
|
244
|
+
if (!data || data.expires < Date.now()) return null
|
|
245
|
+
return data
|
|
246
|
+
},
|
|
247
|
+
delete: async (token) => { await kv.del(`cap:c:${token}`) },
|
|
248
|
+
deleteExpired: async () => {}
|
|
249
|
+
},
|
|
250
|
+
tokens: {
|
|
251
|
+
store: async (key, expires) => { await kv.set(`cap:t:${key}`, { expires }) },
|
|
252
|
+
get: async (key) => {
|
|
253
|
+
const data = await kv.get(`cap:t:${key}`)
|
|
254
|
+
if (!data || data.expires < Date.now()) return null
|
|
255
|
+
return data.expires
|
|
256
|
+
},
|
|
257
|
+
delete: async (key) => { await kv.del(`cap:t:${key}`) },
|
|
258
|
+
deleteExpired: async () => {}
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
function createCap (storage) {
|
|
264
|
+
return new Cap({
|
|
265
|
+
noFSState: true,
|
|
266
|
+
storage: storage || memoryStorage()
|
|
267
|
+
})
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
async function createChallenge (cap) {
|
|
271
|
+
return cap.createChallenge(CHALLENGE_OPTS)
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
async function redeemChallenge (cap, body) {
|
|
275
|
+
const token = body && body.token
|
|
276
|
+
const solutions = body && body.solutions
|
|
277
|
+
if (!token || !solutions || !Array.isArray(solutions)) {
|
|
278
|
+
return { success: false, error: 'Missing token or solutions' }
|
|
279
|
+
}
|
|
280
|
+
return cap.redeemChallenge({ token, solutions })
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
async function validateToken (cap, token) {
|
|
284
|
+
if (!token) return false
|
|
285
|
+
try {
|
|
286
|
+
const { success } = await cap.validateToken(token)
|
|
287
|
+
return !!success
|
|
288
|
+
} catch (e) {
|
|
289
|
+
logger.error('Cap validateToken failed:', e)
|
|
290
|
+
return false
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/** CAPTCHA_PROVIDER=Cap and no CAP_API_ENDPOINT → use embedded Cap */
|
|
295
|
+
function isBuiltinCap (config) {
|
|
296
|
+
return config && config.CAPTCHA_PROVIDER === 'Cap' && !config.CAP_API_ENDPOINT
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
module.exports = {
|
|
300
|
+
createCap,
|
|
301
|
+
memoryStorage,
|
|
302
|
+
mongoStorage,
|
|
303
|
+
lokiStorage,
|
|
304
|
+
tcbStorage,
|
|
305
|
+
kvStorage,
|
|
306
|
+
createChallenge,
|
|
307
|
+
redeemChallenge,
|
|
308
|
+
validateToken,
|
|
309
|
+
isBuiltinCap,
|
|
310
|
+
CHALLENGE_OPTS
|
|
311
|
+
}
|
package/utils/image.js
CHANGED
|
@@ -1,6 +1,4 @@
|
|
|
1
|
-
const
|
|
2
|
-
const os = require('os')
|
|
3
|
-
const path = require('path')
|
|
1
|
+
const crypto = require('crypto')
|
|
4
2
|
const { isUrl } = require('.')
|
|
5
3
|
const { RES_CODE } = require('./constants')
|
|
6
4
|
const { getAxios, getFormData } = require('./lib')
|
|
@@ -8,9 +6,33 @@ const axios = getAxios()
|
|
|
8
6
|
const FormData = getFormData()
|
|
9
7
|
const logger = require('./logger')
|
|
10
8
|
|
|
9
|
+
const MAX_IMAGE_SIZE = 10 * 1024 * 1024
|
|
10
|
+
const IMAGE_TYPES = [
|
|
11
|
+
{
|
|
12
|
+
mimeType: 'image/jpeg',
|
|
13
|
+
extension: 'jpg',
|
|
14
|
+
matches: (body) => body.length >= 3 && body[0] === 0xff && body[1] === 0xd8 && body[2] === 0xff
|
|
15
|
+
},
|
|
16
|
+
{
|
|
17
|
+
mimeType: 'image/png',
|
|
18
|
+
extension: 'png',
|
|
19
|
+
matches: (body) => body.length >= 8 && body.subarray(0, 8).equals(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]))
|
|
20
|
+
},
|
|
21
|
+
{
|
|
22
|
+
mimeType: 'image/gif',
|
|
23
|
+
extension: 'gif',
|
|
24
|
+
matches: (body) => body.length >= 6 && (body.subarray(0, 6).equals(Buffer.from('GIF87a')) || body.subarray(0, 6).equals(Buffer.from('GIF89a')))
|
|
25
|
+
},
|
|
26
|
+
{
|
|
27
|
+
mimeType: 'image/webp',
|
|
28
|
+
extension: 'webp',
|
|
29
|
+
matches: (body) => body.length >= 12 && body.subarray(0, 4).equals(Buffer.from('RIFF')) && body.subarray(8, 12).equals(Buffer.from('WEBP'))
|
|
30
|
+
}
|
|
31
|
+
]
|
|
32
|
+
|
|
11
33
|
const fn = {
|
|
12
34
|
async uploadImage (event, config) {
|
|
13
|
-
const { photo
|
|
35
|
+
const { photo } = event
|
|
14
36
|
const res = {}
|
|
15
37
|
const imageService = config.IMAGE_CDN
|
|
16
38
|
try {
|
|
@@ -22,8 +44,9 @@ const fn = {
|
|
|
22
44
|
} else if (!imageService || !config.IMAGE_CDN_TOKEN) {
|
|
23
45
|
throw new Error('未配置图片上传服务')
|
|
24
46
|
}
|
|
47
|
+
const image = fn.parseImage(photo)
|
|
25
48
|
if (config.NSFW_API_URL) {
|
|
26
|
-
const nsfwResult = await fn.checkNsfw({
|
|
49
|
+
const nsfwResult = await fn.checkNsfw({ image, config })
|
|
27
50
|
if (nsfwResult.rejected) {
|
|
28
51
|
res.code = RES_CODE.NSFW_REJECTED
|
|
29
52
|
res.err = nsfwResult.message
|
|
@@ -32,21 +55,21 @@ const fn = {
|
|
|
32
55
|
}
|
|
33
56
|
// tip: qcloud 图床走前端上传,其他图床走后端上传
|
|
34
57
|
if (imageService === '7bu') {
|
|
35
|
-
await fn.uploadImageToLskyPro({
|
|
58
|
+
await fn.uploadImageToLskyPro({ image, config, res, imageCdn: 'https://7bu.top' })
|
|
36
59
|
} else if (imageService === 'see') {
|
|
37
|
-
await fn.uploadImageToSee({
|
|
60
|
+
await fn.uploadImageToSee({ image, config, res, imageCdn: 'https://s.ee/api/v1/file/upload' })
|
|
38
61
|
} else if (isUrl(imageService)) {
|
|
39
|
-
await fn.uploadImageToLskyPro({
|
|
62
|
+
await fn.uploadImageToLskyPro({ image, config, res, imageCdn: imageService })
|
|
40
63
|
} else if (imageService === 'lskypro') {
|
|
41
|
-
await fn.uploadImageToLskyPro({
|
|
64
|
+
await fn.uploadImageToLskyPro({ image, config, res, imageCdn: config.IMAGE_CDN_URL })
|
|
42
65
|
} else if (imageService === 'piclist') {
|
|
43
|
-
await fn.uploadImageToPicList({
|
|
66
|
+
await fn.uploadImageToPicList({ image, config, res, imageCdn: config.IMAGE_CDN_URL })
|
|
44
67
|
} else if (imageService === 'easyimage') {
|
|
45
|
-
await fn.uploadImageToEasyImage({
|
|
68
|
+
await fn.uploadImageToEasyImage({ image, config, res })
|
|
46
69
|
} else if (imageService === 'chevereto') {
|
|
47
|
-
await fn.uploadImageToChevereto({
|
|
70
|
+
await fn.uploadImageToChevereto({ image, config, res })
|
|
48
71
|
} else if (imageService === 's3') {
|
|
49
|
-
await fn.uploadImageToS3({
|
|
72
|
+
await fn.uploadImageToS3({ image, config, res })
|
|
50
73
|
} else {
|
|
51
74
|
throw new Error('不支持的图片上传服务')
|
|
52
75
|
}
|
|
@@ -57,13 +80,13 @@ const fn = {
|
|
|
57
80
|
}
|
|
58
81
|
return res
|
|
59
82
|
},
|
|
60
|
-
async checkNsfw ({
|
|
83
|
+
async checkNsfw ({ image, config }) {
|
|
61
84
|
const result = { rejected: false, message: '' }
|
|
62
85
|
try {
|
|
63
86
|
const threshold = parseFloat(config.NSFW_THRESHOLD) || 0.5
|
|
64
87
|
const apiUrl = config.NSFW_API_URL.replace(/\/$/, '')
|
|
65
88
|
const formData = new FormData()
|
|
66
|
-
|
|
89
|
+
fn.appendImage(formData, 'image', image)
|
|
67
90
|
const response = await axios.post(`${apiUrl}/classify`, formData, {
|
|
68
91
|
headers: {
|
|
69
92
|
...formData.getHeaders()
|
|
@@ -84,10 +107,10 @@ const fn = {
|
|
|
84
107
|
}
|
|
85
108
|
return result
|
|
86
109
|
},
|
|
87
|
-
async uploadImageToSee ({
|
|
110
|
+
async uploadImageToSee ({ image, config, res, imageCdn }) {
|
|
88
111
|
// S.EE 图床 https://s.ee (原 SM.MS)
|
|
89
112
|
const formData = new FormData()
|
|
90
|
-
|
|
113
|
+
fn.appendImage(formData, 'smfile', image)
|
|
91
114
|
const uploadResult = await axios.post(imageCdn, formData, {
|
|
92
115
|
headers: {
|
|
93
116
|
...formData.getHeaders(),
|
|
@@ -100,10 +123,10 @@ const fn = {
|
|
|
100
123
|
throw new Error(uploadResult.data.message)
|
|
101
124
|
}
|
|
102
125
|
},
|
|
103
|
-
async uploadImageToLskyPro ({
|
|
126
|
+
async uploadImageToLskyPro ({ image, config, res, imageCdn }) {
|
|
104
127
|
// 自定义兰空图床(v2)URL
|
|
105
128
|
const formData = new FormData()
|
|
106
|
-
|
|
129
|
+
fn.appendImage(formData, 'file', image)
|
|
107
130
|
if (process.env.TWIKOO_LSKY_STRATEGY_ID) {
|
|
108
131
|
formData.append('strategy_id', parseInt(process.env.TWIKOO_LSKY_STRATEGY_ID))
|
|
109
132
|
}
|
|
@@ -125,11 +148,11 @@ const fn = {
|
|
|
125
148
|
throw new Error(uploadResult.data.message)
|
|
126
149
|
}
|
|
127
150
|
},
|
|
128
|
-
async uploadImageToPicList ({
|
|
151
|
+
async uploadImageToPicList ({ image, config, res, imageCdn }) {
|
|
129
152
|
// PicList https://piclist.cn/ 高效的云存储和图床平台管理工具
|
|
130
153
|
// 鉴权使用 query 参数 key
|
|
131
154
|
const formData = new FormData()
|
|
132
|
-
|
|
155
|
+
fn.appendImage(formData, 'file', image)
|
|
133
156
|
let url = `${imageCdn}/upload`
|
|
134
157
|
// 如果填写了 key 则拼接 url
|
|
135
158
|
if (config.IMAGE_CDN_TOKEN) {
|
|
@@ -143,7 +166,7 @@ const fn = {
|
|
|
143
166
|
throw new Error(uploadResult.data.message)
|
|
144
167
|
}
|
|
145
168
|
},
|
|
146
|
-
async uploadImageToEasyImage ({
|
|
169
|
+
async uploadImageToEasyImage ({ image, config, res }) {
|
|
147
170
|
// EasyImage2.0 https://github.com/icret/EasyImages2.0 简单图床 - 一款功能强大无数据库的图床 2.0版
|
|
148
171
|
try {
|
|
149
172
|
// 参数校验
|
|
@@ -158,9 +181,7 @@ const fn = {
|
|
|
158
181
|
// 添加 token 参数到 Body
|
|
159
182
|
formData.append('token', config.IMAGE_CDN_TOKEN)
|
|
160
183
|
// 添加图片文件(固定参数名 image)
|
|
161
|
-
|
|
162
|
-
filename: fileName
|
|
163
|
-
})
|
|
184
|
+
fn.appendImage(formData, 'image', image)
|
|
164
185
|
// 发送请求
|
|
165
186
|
const uploadResult = await axios.post(config.IMAGE_CDN_URL, formData, {
|
|
166
187
|
headers: {
|
|
@@ -193,7 +214,7 @@ const fn = {
|
|
|
193
214
|
throw new Error(errorMsg)
|
|
194
215
|
}
|
|
195
216
|
},
|
|
196
|
-
async uploadImageToChevereto ({
|
|
217
|
+
async uploadImageToChevereto ({ image, config, res }) {
|
|
197
218
|
if (!config.IMAGE_CDN_URL) {
|
|
198
219
|
throw new Error('未配置 Chevereto 站点地址 (IMAGE_CDN_URL)')
|
|
199
220
|
}
|
|
@@ -202,7 +223,7 @@ const fn = {
|
|
|
202
223
|
}
|
|
203
224
|
const formData = new FormData()
|
|
204
225
|
formData.append('key', config.IMAGE_CDN_TOKEN)
|
|
205
|
-
|
|
226
|
+
fn.appendImage(formData, 'source', image)
|
|
206
227
|
formData.append('format', 'json')
|
|
207
228
|
const apiUrl = config.IMAGE_CDN_URL.replace(/\/$/, '') + '/api/1/upload'
|
|
208
229
|
const uploadResult = await axios.post(apiUrl, formData, {
|
|
@@ -222,7 +243,7 @@ const fn = {
|
|
|
222
243
|
throw new Error(`Chevereto 上传失败: ${errMsg}`)
|
|
223
244
|
}
|
|
224
245
|
},
|
|
225
|
-
async uploadImageToS3 ({
|
|
246
|
+
async uploadImageToS3 ({ image, config, res }) {
|
|
226
247
|
// 使用原生 crypto + axios 实现 AWS Signature V4,无需引入 SDK
|
|
227
248
|
if (!config.S3_BUCKET) {
|
|
228
249
|
throw new Error('未配置 S3 存储桶名称 (S3_BUCKET)')
|
|
@@ -233,15 +254,11 @@ const fn = {
|
|
|
233
254
|
if (!config.S3_SECRET_ACCESS_KEY) {
|
|
234
255
|
throw new Error('未配置 S3 Secret Access Key (S3_SECRET_ACCESS_KEY)')
|
|
235
256
|
}
|
|
236
|
-
const crypto = require('crypto')
|
|
237
257
|
const region = config.S3_REGION || 'us-east-1'
|
|
238
|
-
|
|
239
|
-
const base64 = photo.split(';base64,').pop()
|
|
240
|
-
const mimeType = photo.split(';base64,')[0].replace('data:', '') || 'image/webp'
|
|
241
|
-
const body = Buffer.from(base64, 'base64')
|
|
258
|
+
const { body, mimeType, fileName } = image
|
|
242
259
|
// 构建对象 key
|
|
243
260
|
const prefix = config.S3_PATH_PREFIX ? config.S3_PATH_PREFIX.replace(/\/$/, '') + '/' : ''
|
|
244
|
-
const key = `${prefix}${
|
|
261
|
+
const key = `${prefix}${fileName}`
|
|
245
262
|
const forcePathStyle = String(config.S3_FORCE_PATH_STYLE).trim().toLowerCase() !== 'false'
|
|
246
263
|
let endpoint
|
|
247
264
|
let s3Base
|
|
@@ -315,11 +332,44 @@ const fn = {
|
|
|
315
332
|
}
|
|
316
333
|
res.data = { url: fileUrl }
|
|
317
334
|
},
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
335
|
+
parseImage (photo) {
|
|
336
|
+
if (typeof photo !== 'string' || photo.length > Math.ceil(MAX_IMAGE_SIZE / 3) * 4 + 64) {
|
|
337
|
+
throw new Error('图片大小不能超过 10 MB')
|
|
338
|
+
}
|
|
339
|
+
const header = /^data:([a-z0-9.+-]+\/[a-z0-9.+-]+);base64,/i.exec(photo)
|
|
340
|
+
if (!header) {
|
|
341
|
+
throw new Error('图片数据格式不合法')
|
|
342
|
+
}
|
|
343
|
+
const declaredMimeType = header[1].toLowerCase()
|
|
344
|
+
const base64 = photo.slice(header[0].length)
|
|
345
|
+
if (!base64 || base64.length % 4 !== 0 || !/^[a-z0-9+/]*={0,2}$/i.test(base64)) {
|
|
346
|
+
throw new Error('图片数据格式不合法')
|
|
347
|
+
}
|
|
348
|
+
const padding = base64.endsWith('==') ? 2 : (base64.endsWith('=') ? 1 : 0)
|
|
349
|
+
const decodedSize = base64.length * 3 / 4 - padding
|
|
350
|
+
if (decodedSize > MAX_IMAGE_SIZE) {
|
|
351
|
+
throw new Error('图片大小不能超过 10 MB')
|
|
352
|
+
}
|
|
353
|
+
const body = Buffer.from(base64, 'base64')
|
|
354
|
+
const imageType = IMAGE_TYPES.find((type) => type.matches(body))
|
|
355
|
+
if (!imageType) {
|
|
356
|
+
throw new Error('仅支持 JPEG、PNG、GIF 和 WebP 图片')
|
|
357
|
+
}
|
|
358
|
+
if (declaredMimeType !== imageType.mimeType) {
|
|
359
|
+
throw new Error('图片 MIME 类型与文件内容不匹配')
|
|
360
|
+
}
|
|
361
|
+
return {
|
|
362
|
+
body,
|
|
363
|
+
mimeType: imageType.mimeType,
|
|
364
|
+
fileName: `${crypto.randomBytes(16).toString('hex')}.${imageType.extension}`
|
|
365
|
+
}
|
|
366
|
+
},
|
|
367
|
+
appendImage (formData, fieldName, image) {
|
|
368
|
+
formData.append(fieldName, image.body, {
|
|
369
|
+
filename: image.fileName,
|
|
370
|
+
contentType: image.mimeType,
|
|
371
|
+
knownLength: image.body.length
|
|
372
|
+
})
|
|
323
373
|
}
|
|
324
374
|
}
|
|
325
375
|
|
package/utils/index.js
CHANGED
|
@@ -268,7 +268,7 @@ const fn = {
|
|
|
268
268
|
if (qqApiKey) {
|
|
269
269
|
headers.Authorization = `Bearer ${qqApiKey}`
|
|
270
270
|
}
|
|
271
|
-
const result = await axios.get(`https://v1.
|
|
271
|
+
const result = await axios.get(`https://v1.tqq.me/v1/qqname?qq=${qqNum}`, { headers })
|
|
272
272
|
if (result.data?.code === 200 && result.data?.data?.nick) {
|
|
273
273
|
return result.data.data.nick
|
|
274
274
|
}
|
|
@@ -366,11 +366,17 @@ const fn = {
|
|
|
366
366
|
throw new Error('极验验证码检测失败: ' + e.message)
|
|
367
367
|
}
|
|
368
368
|
},
|
|
369
|
-
async checkCapCaptcha ({ capToken, capSecretKey, capApiEndpoint }) {
|
|
369
|
+
async checkCapCaptcha ({ capToken, capSecretKey, capApiEndpoint, cap }) {
|
|
370
370
|
try {
|
|
371
|
-
//
|
|
371
|
+
// 内嵌 Cap:直接 validateToken,无需外部 Standalone
|
|
372
|
+
if (cap) {
|
|
373
|
+
const { validateToken } = require('./cap')
|
|
374
|
+
const ok = await validateToken(cap, capToken)
|
|
375
|
+
if (!ok) throw new Error('验证码错误')
|
|
376
|
+
return
|
|
377
|
+
}
|
|
378
|
+
// 外部 Cap Standalone:HTTP siteverify
|
|
372
379
|
const endpoint = capApiEndpoint.replace(/\/$/, '')
|
|
373
|
-
// Cap 的 siteverify 端点
|
|
374
380
|
const url = `${endpoint}/siteverify`
|
|
375
381
|
logger.log('Cap验证码验证URL:', url)
|
|
376
382
|
logger.log('Cap验证码验证参数:', { secret: capSecretKey ? '***' : undefined, response: capToken.substring(0, 20) + '...' })
|
|
@@ -412,8 +418,7 @@ const fn = {
|
|
|
412
418
|
HIGHLIGHT_THEME: config.HIGHLIGHT_THEME,
|
|
413
419
|
HIGHLIGHT_PLUGIN: config.HIGHLIGHT_PLUGIN,
|
|
414
420
|
LIMIT_LENGTH: config.LIMIT_LENGTH,
|
|
415
|
-
CAPTCHA_PROVIDER: config.CAPTCHA_PROVIDER
|
|
416
|
-
QQ_API_KEY: config.QQ_API_KEY
|
|
421
|
+
CAPTCHA_PROVIDER: config.CAPTCHA_PROVIDER
|
|
417
422
|
}
|
|
418
423
|
|
|
419
424
|
// 仅在明确指定使用 Turnstile 时下发 Turnstile 的 site key
|
|
@@ -426,9 +431,13 @@ const fn = {
|
|
|
426
431
|
baseConfig.GEETEST_CAPTCHA_ID = config.GEETEST_CAPTCHA_ID
|
|
427
432
|
}
|
|
428
433
|
|
|
429
|
-
//
|
|
434
|
+
// Cap:有外部 endpoint 则下发;否则标记 builtin,前端走 twikoo 事件代理
|
|
430
435
|
if (config.CAPTCHA_PROVIDER === 'Cap') {
|
|
431
|
-
|
|
436
|
+
if (config.CAP_API_ENDPOINT) {
|
|
437
|
+
baseConfig.CAP_API_ENDPOINT = config.CAP_API_ENDPOINT
|
|
438
|
+
} else {
|
|
439
|
+
baseConfig.CAP_BUILTIN = true
|
|
440
|
+
}
|
|
432
441
|
}
|
|
433
442
|
|
|
434
443
|
return {
|
package/utils/lib.js
CHANGED
|
@@ -71,13 +71,5 @@ module.exports = {
|
|
|
71
71
|
getXml2js () {
|
|
72
72
|
const xml2js = require('xml2js') // XML 解析
|
|
73
73
|
return xml2js
|
|
74
|
-
},
|
|
75
|
-
getOpenAIClient (config) {
|
|
76
|
-
const OpenAI = require('openai') // OpenAI 的 SDK,用于反垃圾
|
|
77
|
-
const openaiClient = new OpenAI({
|
|
78
|
-
apiKey: config.LLM_API_KEY,
|
|
79
|
-
baseURL: config.LLM_API_ENDPOINT || 'https://api.deepseek.com'
|
|
80
|
-
})
|
|
81
|
-
return openaiClient
|
|
82
74
|
}
|
|
83
75
|
}
|
package/utils/notify.js
CHANGED
|
@@ -9,6 +9,18 @@ const pushoo = getPushoo()
|
|
|
9
9
|
const { RES_CODE } = require('./constants')
|
|
10
10
|
const logger = require('./logger')
|
|
11
11
|
|
|
12
|
+
// HTML 实体转义,防止用户可控字段(昵称、邮箱等)在邮件 HTML 模板中造成存储型 XSS
|
|
13
|
+
// 仅在渲染邮件时转义,不修改数据库的存储内容
|
|
14
|
+
function escapeHtml (str) {
|
|
15
|
+
if (typeof str !== 'string') return str
|
|
16
|
+
return str
|
|
17
|
+
.replace(/&/g, '&')
|
|
18
|
+
.replace(/</g, '<')
|
|
19
|
+
.replace(/>/g, '>')
|
|
20
|
+
.replace(/"/g, '"')
|
|
21
|
+
.replace(/'/g, ''')
|
|
22
|
+
}
|
|
23
|
+
|
|
12
24
|
let nodemailer
|
|
13
25
|
|
|
14
26
|
function lazilyGetNodemailer () {
|
|
@@ -85,13 +97,13 @@ const fn = {
|
|
|
85
97
|
return
|
|
86
98
|
}
|
|
87
99
|
const SITE_NAME = config.SITE_NAME
|
|
88
|
-
const NICK = comment.nick
|
|
100
|
+
const NICK = escapeHtml(comment.nick)
|
|
89
101
|
const IMG = getAvatar(comment, config)
|
|
90
102
|
const IP = comment.ip
|
|
91
|
-
const MAIL = comment.mail
|
|
103
|
+
const MAIL = escapeHtml(comment.mail)
|
|
92
104
|
const COMMENT = comment.comment
|
|
93
105
|
const SITE_URL = config.SITE_URL
|
|
94
|
-
const POST_URL = fn.appendHashToUrl(comment.href || SITE_URL + comment.url, comment.id)
|
|
106
|
+
const POST_URL = escapeHtml(fn.appendHashToUrl(comment.href || SITE_URL + comment.url, comment.id))
|
|
95
107
|
const emailSubject = config.MAIL_SUBJECT_ADMIN || `${SITE_NAME}上有新评论了`
|
|
96
108
|
let emailContent
|
|
97
109
|
if (config.MAIL_TEMPLATE_ADMIN) {
|
|
@@ -160,12 +172,12 @@ const fn = {
|
|
|
160
172
|
// 即时消息推送内容获取
|
|
161
173
|
getIMPushContent (comment, config) {
|
|
162
174
|
const SITE_NAME = config.SITE_NAME
|
|
163
|
-
const NICK = comment.nick
|
|
164
|
-
const MAIL = comment.mail
|
|
175
|
+
const NICK = escapeHtml(comment.nick)
|
|
176
|
+
const MAIL = escapeHtml(comment.mail)
|
|
165
177
|
const IP = comment.ip
|
|
166
178
|
const COMMENT = $(comment.comment).text()
|
|
167
179
|
const SITE_URL = config.SITE_URL
|
|
168
|
-
const POST_URL = fn.appendHashToUrl(comment.href || SITE_URL + comment.url, comment.id)
|
|
180
|
+
const POST_URL = escapeHtml(fn.appendHashToUrl(comment.href || SITE_URL + comment.url, comment.id))
|
|
169
181
|
const subject = config.MAIL_SUBJECT_ADMIN || `${SITE_NAME}有新评论了`
|
|
170
182
|
const content = `评论人:${NICK} ([${MAIL}](mailto:${MAIL}))
|
|
171
183
|
|
|
@@ -199,14 +211,14 @@ const fn = {
|
|
|
199
211
|
logger.info('回复自己的评论,不邮件通知')
|
|
200
212
|
return
|
|
201
213
|
}
|
|
202
|
-
const PARENT_NICK = parentComment.nick
|
|
214
|
+
const PARENT_NICK = escapeHtml(parentComment.nick)
|
|
203
215
|
const IMG = getAvatar(currentComment, config)
|
|
204
216
|
const PARENT_IMG = getAvatar(parentComment, config)
|
|
205
217
|
const SITE_NAME = config.SITE_NAME
|
|
206
|
-
const NICK = currentComment.nick
|
|
218
|
+
const NICK = escapeHtml(currentComment.nick)
|
|
207
219
|
const COMMENT = currentComment.comment
|
|
208
220
|
const PARENT_COMMENT = parentComment.comment
|
|
209
|
-
const POST_URL = fn.appendHashToUrl(currentComment.href || config.SITE_URL + currentComment.url, currentComment.id)
|
|
221
|
+
const POST_URL = escapeHtml(fn.appendHashToUrl(currentComment.href || config.SITE_URL + currentComment.url, currentComment.id))
|
|
210
222
|
const SITE_URL = config.SITE_URL
|
|
211
223
|
const emailSubject = config.MAIL_SUBJECT || `${PARENT_NICK},您在『${SITE_NAME}』上的评论收到了回复`
|
|
212
224
|
let emailContent
|
package/utils/spam.js
CHANGED
|
@@ -1,8 +1,7 @@
|
|
|
1
1
|
const {
|
|
2
2
|
getAkismetClient,
|
|
3
3
|
getCryptoJS,
|
|
4
|
-
getTencentcloud
|
|
5
|
-
getOpenAIClient
|
|
4
|
+
getTencentcloud
|
|
6
5
|
} = require('./lib')
|
|
7
6
|
const {
|
|
8
7
|
equalsMail
|
|
@@ -13,9 +12,7 @@ const CryptoJS = getCryptoJS()
|
|
|
13
12
|
const logger = require('./logger')
|
|
14
13
|
|
|
15
14
|
let tencentcloud
|
|
16
|
-
let
|
|
17
|
-
let _openaiApiKey
|
|
18
|
-
let _openaiEndpoint
|
|
15
|
+
let generateTextPromise
|
|
19
16
|
|
|
20
17
|
function getTencentCloud () {
|
|
21
18
|
if (!tencentcloud) {
|
|
@@ -28,19 +25,16 @@ function getTencentCloud () {
|
|
|
28
25
|
return tencentcloud
|
|
29
26
|
}
|
|
30
27
|
|
|
31
|
-
function
|
|
32
|
-
if (
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
28
|
+
function getGenerateText () {
|
|
29
|
+
if (!generateTextPromise) {
|
|
30
|
+
generateTextPromise = import('@xsai/generate-text')
|
|
31
|
+
.then(({ generateText }) => generateText)
|
|
32
|
+
.catch((error) => {
|
|
33
|
+
generateTextPromise = null
|
|
34
|
+
throw error
|
|
35
|
+
})
|
|
36
36
|
}
|
|
37
|
-
return
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
function isConfigChanged (config) {
|
|
41
|
-
return !openai ||
|
|
42
|
-
_openaiApiKey !== (config.LLM_API_KEY || '') ||
|
|
43
|
-
_openaiEndpoint !== (config.LLM_API_ENDPOINT || '')
|
|
37
|
+
return generateTextPromise
|
|
44
38
|
}
|
|
45
39
|
|
|
46
40
|
// 提取json结构的函数
|
|
@@ -126,8 +120,6 @@ async function checkByLLM (comment, config) {
|
|
|
126
120
|
const maxRetries = Number(config.LLM_MAX_RETRIES) || 3
|
|
127
121
|
let lastError = ''
|
|
128
122
|
|
|
129
|
-
const openai = getOpenAI(config)
|
|
130
|
-
|
|
131
123
|
// 网络/Provider 异常或者格式校验不通过会进入重试逻辑
|
|
132
124
|
for (let attempt = 1; attempt <= maxRetries; attempt++) {
|
|
133
125
|
if (attempt > 1) {
|
|
@@ -142,13 +134,16 @@ async function checkByLLM (comment, config) {
|
|
|
142
134
|
messages = buildMessages(comment, lastError)
|
|
143
135
|
}
|
|
144
136
|
|
|
145
|
-
const
|
|
146
|
-
|
|
147
|
-
|
|
137
|
+
const generateText = await getGenerateText()
|
|
138
|
+
const chatCompletion = await generateText({
|
|
139
|
+
apiKey: config.LLM_API_KEY,
|
|
140
|
+
baseURL: config.LLM_API_ENDPOINT || 'https://api.deepseek.com/v1',
|
|
141
|
+
model: config.LLM_MODEL || 'deepseek-chat',
|
|
142
|
+
responseFormat: { type: 'json_object' },
|
|
148
143
|
messages
|
|
149
144
|
})
|
|
150
145
|
|
|
151
|
-
const rawText = chatCompletion.
|
|
146
|
+
const rawText = chatCompletion.text || ''
|
|
152
147
|
|
|
153
148
|
const extracted = extractJson(rawText)
|
|
154
149
|
const repaired = repairJson(extracted)
|