twikoo-func 1.7.14 → 1.7.15

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 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.14",
3
+ "version": "1.7.15",
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,7 @@
11
11
  },
12
12
  "homepage": "https://twikoo.js.org",
13
13
  "dependencies": {
14
+ "@cap.js/server": "^4.0.5",
14
15
  "@cloudbase/manager-node": "^3.9.0",
15
16
  "@cloudbase/node-sdk": "^2.5.0",
16
17
  "@imaegoo/node-ip2region": "^2.1.1",
@@ -25,9 +26,9 @@
25
26
  "jsdom": "^16.4.0",
26
27
  "marked": "^4.0.12",
27
28
  "nodemailer": "^7.0.11",
28
- "openai": "^6.45.0",
29
29
  "pushoo": "latest",
30
30
  "tencentcloud-sdk-nodejs": "^4.0.65",
31
- "xml2js": "^0.6.0"
31
+ "xml2js": "^0.6.0",
32
+ "xsai": "^0.2.2"
32
33
  }
33
34
  }
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/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.nsuuu.com/api/qqname?qq=${qqNum}`, { headers })
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) + '...' })
@@ -426,9 +432,13 @@ const fn = {
426
432
  baseConfig.GEETEST_CAPTCHA_ID = config.GEETEST_CAPTCHA_ID
427
433
  }
428
434
 
429
- // 仅在明确指定使用 Cap 时下发 Cap api endpoint
435
+ // Cap:有外部 endpoint 则下发;否则标记 builtin,前端走 twikoo 事件代理
430
436
  if (config.CAPTCHA_PROVIDER === 'Cap') {
431
- baseConfig.CAP_API_ENDPOINT = config.CAP_API_ENDPOINT
437
+ if (config.CAP_API_ENDPOINT) {
438
+ baseConfig.CAP_API_ENDPOINT = config.CAP_API_ENDPOINT
439
+ } else {
440
+ baseConfig.CAP_BUILTIN = true
441
+ }
432
442
  }
433
443
 
434
444
  return {
package/utils/lib.js CHANGED
@@ -73,10 +73,10 @@ module.exports = {
73
73
  return xml2js
74
74
  },
75
75
  getOpenAIClient (config) {
76
- const OpenAI = require('openai') // OpenAI SDK,用于反垃圾
77
- const openaiClient = new OpenAI({
76
+ const { createXSClient } = require('xsai') // xsai SDK
77
+ const openaiClient = createXSClient({
78
78
  apiKey: config.LLM_API_KEY,
79
- baseURL: config.LLM_API_ENDPOINT || 'https://api.deepseek.com'
79
+ baseURL: config.LLM_API_ENDPOINT || 'https://api.deepseek.com/v1'
80
80
  })
81
81
  return openaiClient
82
82
  }
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,6 @@ const CryptoJS = getCryptoJS()
13
12
  const logger = require('./logger')
14
13
 
15
14
  let tencentcloud
16
- let openai
17
- let _openaiApiKey
18
- let _openaiEndpoint
19
15
 
20
16
  function getTencentCloud () {
21
17
  if (!tencentcloud) {
@@ -28,21 +24,6 @@ function getTencentCloud () {
28
24
  return tencentcloud
29
25
  }
30
26
 
31
- function getOpenAI (config) {
32
- if (isConfigChanged(config)) {
33
- _openaiApiKey = config.LLM_API_KEY || ''
34
- _openaiEndpoint = config.LLM_API_ENDPOINT || ''
35
- openai = getOpenAIClient(config)
36
- }
37
- return openai
38
- }
39
-
40
- function isConfigChanged (config) {
41
- return !openai ||
42
- _openaiApiKey !== (config.LLM_API_KEY || '') ||
43
- _openaiEndpoint !== (config.LLM_API_ENDPOINT || '')
44
- }
45
-
46
27
  // 提取json结构的函数
47
28
  function extractJson (rawText) {
48
29
  if (!rawText) return ''
@@ -126,8 +107,6 @@ async function checkByLLM (comment, config) {
126
107
  const maxRetries = Number(config.LLM_MAX_RETRIES) || 3
127
108
  let lastError = ''
128
109
 
129
- const openai = getOpenAI(config)
130
-
131
110
  // 网络/Provider 异常或者格式校验不通过会进入重试逻辑
132
111
  for (let attempt = 1; attempt <= maxRetries; attempt++) {
133
112
  if (attempt > 1) {
@@ -142,13 +121,14 @@ async function checkByLLM (comment, config) {
142
121
  messages = buildMessages(comment, lastError)
143
122
  }
144
123
 
145
- const chatCompletion = await openai.chat.completions.create({
146
- model: config.LLM_MODEL || 'deepseek-v4-pro',
147
- response_format: { type: 'json_object' },
124
+ const { generateText } = require('xsai')
125
+ const chatCompletion = await generateText({
126
+ model: config.LLM_MODEL || 'deepseek-chat',
127
+ responseFormat: { type: 'json_object' },
148
128
  messages
149
129
  })
150
130
 
151
- const rawText = chatCompletion.choices[0].message.content || ''
131
+ const rawText = chatCompletion.text || ''
152
132
 
153
133
  const extracted = extractJson(rawText)
154
134
  const repaired = repairJson(extracted)