twikoo-func 1.7.24 → 2.0.0-beta.2

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/utils/notify.js DELETED
@@ -1,309 +0,0 @@
1
- const { equalsMail, getAvatar, isValidEmail } = require('.')
2
- const {
3
- getHtmlToText,
4
- getNodemailer,
5
- getPushoo
6
- } = require('./lib')
7
- const htmlToText = getHtmlToText()
8
- const pushoo = getPushoo()
9
- const { RES_CODE } = require('./constants')
10
- const logger = require('./logger')
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, '&lt;')
19
- .replace(/>/g, '&gt;')
20
- .replace(/"/g, '&quot;')
21
- .replace(/'/g, '&#39;')
22
- }
23
-
24
- let nodemailer
25
-
26
- function lazilyGetNodemailer () {
27
- return nodemailer ?? (nodemailer = getNodemailer())
28
- }
29
-
30
- let transporter
31
-
32
- const fn = {
33
- // 发送通知
34
- async sendNotice (comment, config, getParentComment) {
35
- if (comment.isSpam && config.NOTIFY_SPAM === 'false') return
36
- await Promise.all([
37
- fn.noticeMaster(comment, config),
38
- fn.noticeReply(comment, config, getParentComment),
39
- fn.noticePushoo(comment, config)
40
- ]).catch(err => {
41
- logger.error('通知异常:', err)
42
- })
43
- },
44
- // 初始化邮件插件
45
- async initMailer ({ config, throwErr = false } = {}) {
46
- try {
47
- if (!config || !config.SMTP_USER || !config.SMTP_PASS) {
48
- throw new Error('数据库配置不存在')
49
- }
50
- const transportConfig = {
51
- auth: {
52
- user: config.SMTP_USER,
53
- pass: config.SMTP_PASS
54
- }
55
- }
56
- if (config.SMTP_SERVICE) {
57
- transportConfig.service = config.SMTP_SERVICE
58
- } else if (config.SMTP_HOST) {
59
- transportConfig.host = config.SMTP_HOST
60
- transportConfig.port = parseInt(config.SMTP_PORT)
61
- transportConfig.secure = config.SMTP_SECURE === 'true'
62
- } else {
63
- throw new Error('SMTP 服务器没有配置')
64
- }
65
- transporter = lazilyGetNodemailer().createTransport(transportConfig)
66
- try {
67
- const success = await transporter.verify()
68
- if (success) logger.info('SMTP 邮箱配置正常')
69
- } catch (error) {
70
- throw new Error('SMTP 邮箱配置异常:' + error.message)
71
- }
72
- return true
73
- } catch (e) {
74
- if (throwErr) {
75
- logger.error('邮件初始化异常:', e.message)
76
- throw e
77
- } else {
78
- logger.warn('邮件初始化异常:', e.message)
79
- }
80
- return false
81
- }
82
- },
83
- // 博主通知
84
- async noticeMaster (comment, config) {
85
- if (!transporter && !await fn.initMailer({ config })) {
86
- logger.info('未配置邮箱或邮箱配置有误,不通知')
87
- return
88
- }
89
- if (equalsMail(config.BLOGGER_EMAIL, comment.mail)) {
90
- logger.info('博主本人评论,不发送通知给博主')
91
- return
92
- }
93
- // 判断是否存在即时消息推送配置
94
- const hasIMPushConfig = config.PUSHOO_CHANNEL && config.PUSHOO_TOKEN
95
- if (hasIMPushConfig && config.SC_MAIL_NOTIFY !== 'true') {
96
- logger.info('存在即时消息推送配置,默认不发送邮件给博主,您可以在管理面板修改此行为')
97
- return
98
- }
99
- const SITE_NAME = config.SITE_NAME
100
- const NICK = escapeHtml(comment.nick)
101
- const IMG = getAvatar(comment, config)
102
- const IP = comment.ip
103
- const MAIL = escapeHtml(comment.mail)
104
- const COMMENT = comment.comment
105
- const SITE_URL = config.SITE_URL
106
- const POST_URL = escapeHtml(fn.appendHashToUrl(comment.href || SITE_URL + comment.url, comment.id))
107
- const emailSubject = config.MAIL_SUBJECT_ADMIN || `${SITE_NAME}上有新评论了`
108
- let emailContent
109
- if (config.MAIL_TEMPLATE_ADMIN) {
110
- emailContent = config.MAIL_TEMPLATE_ADMIN
111
- .replace(/\${SITE_URL}/g, SITE_URL)
112
- .replace(/\${SITE_NAME}/g, SITE_NAME)
113
- .replace(/\${NICK}/g, NICK)
114
- .replace(/\${IMG}/g, IMG)
115
- .replace(/\${IP}/g, IP)
116
- .replace(/\${MAIL}/g, MAIL)
117
- .replace(/\${COMMENT}/g, COMMENT)
118
- .replace(/\${POST_URL}/g, POST_URL)
119
- } else {
120
- emailContent = `
121
- <div style="border-top:2px solid #12addb;box-shadow:0 1px 3px #aaaaaa;line-height:180%;padding:0 15px 12px;margin:50px auto;font-size:12px;">
122
- <h2 style="border-bottom:1px solid #dddddd;font-size:14px;font-weight:normal;padding:13px 0 10px 8px;">
123
- 您在<a style="text-decoration:none;color: #12addb;" href="${SITE_URL}" target="_blank">${SITE_NAME}</a>上的文章有了新的评论
124
- </h2>
125
- <p><strong>${NICK}</strong>回复说:</p>
126
- <div style="background-color: #f5f5f5;padding: 10px 15px;margin:18px 0;word-wrap:break-word;">${COMMENT}</div>
127
- <p>您可以点击<a style="text-decoration:none; color:#12addb" href="${POST_URL}" target="_blank">查看回复的完整內容</a><br></p>
128
- </div>`
129
- }
130
- const toMail = config.BLOGGER_EMAIL || config.SENDER_EMAIL
131
- if (!isValidEmail(toMail)) {
132
- logger.warn('博主邮箱格式不合法,跳过发送博主通知:', toMail)
133
- return
134
- }
135
- let sendResult
136
- try {
137
- sendResult = await transporter.sendMail({
138
- from: `"${config.SENDER_NAME}" <${config.SENDER_EMAIL}>`,
139
- to: toMail,
140
- subject: emailSubject,
141
- html: emailContent
142
- })
143
- } catch (e) {
144
- sendResult = e
145
- }
146
- logger.log('博主通知结果:', sendResult)
147
- return sendResult
148
- },
149
- // 即时消息通知
150
- async noticePushoo (comment, config) {
151
- if (!config.PUSHOO_CHANNEL || !config.PUSHOO_TOKEN) {
152
- logger.info('没有配置 pushoo,放弃即时消息通知')
153
- return
154
- }
155
- if (equalsMail(config.BLOGGER_EMAIL, comment.mail)) {
156
- logger.info('博主本人评论,不发送通知给博主')
157
- return
158
- }
159
- const pushContent = fn.getIMPushContent(comment, config)
160
- const sendResult = await pushoo(config.PUSHOO_CHANNEL, {
161
- token: config.PUSHOO_TOKEN,
162
- title: pushContent.subject,
163
- content: pushContent.content,
164
- options: {
165
- bark: {
166
- url: pushContent.url
167
- }
168
- }
169
- })
170
- logger.info('即时消息通知结果:', sendResult)
171
- },
172
- // 即时消息推送内容获取
173
- getIMPushContent (comment, config) {
174
- const SITE_NAME = config.SITE_NAME
175
- const NICK = escapeHtml(comment.nick)
176
- const MAIL = escapeHtml(comment.mail)
177
- const IP = comment.ip
178
- const COMMENT = htmlToText(comment.comment)
179
- const SITE_URL = config.SITE_URL
180
- const POST_URL = escapeHtml(fn.appendHashToUrl(comment.href || SITE_URL + comment.url, comment.id))
181
- const subject = config.MAIL_SUBJECT_ADMIN || `${SITE_NAME}有新评论了`
182
- const content = `评论人:${NICK} ([${MAIL}](mailto:${MAIL}))
183
-
184
- 评论人IP:${IP}
185
-
186
- 评论内容:${COMMENT}
187
-
188
- 原文链接:[${POST_URL}](${POST_URL})`
189
- return {
190
- subject,
191
- content,
192
- url: POST_URL
193
- }
194
- },
195
- // 回复通知
196
- async noticeReply (currentComment, config, getParentComment) {
197
- if (!currentComment.pid) {
198
- logger.info('无父级评论,不通知')
199
- return
200
- }
201
- if (!transporter && !await fn.initMailer({ config })) {
202
- logger.info('未配置邮箱或邮箱配置有误,不通知')
203
- return
204
- }
205
- const parentComment = await getParentComment(currentComment)
206
- if (equalsMail(config.BLOGGER_EMAIL, parentComment.mail)) {
207
- logger.info('回复给博主,因为会发博主通知邮件,所以不再重复通知')
208
- return
209
- }
210
- if (equalsMail(currentComment.mail, parentComment.mail)) {
211
- logger.info('回复自己的评论,不邮件通知')
212
- return
213
- }
214
- const PARENT_NICK = escapeHtml(parentComment.nick)
215
- const IMG = getAvatar(currentComment, config)
216
- const PARENT_IMG = getAvatar(parentComment, config)
217
- const SITE_NAME = config.SITE_NAME
218
- const NICK = escapeHtml(currentComment.nick)
219
- const COMMENT = currentComment.comment
220
- const PARENT_COMMENT = parentComment.comment
221
- const POST_URL = escapeHtml(fn.appendHashToUrl(currentComment.href || config.SITE_URL + currentComment.url, currentComment.id))
222
- const SITE_URL = config.SITE_URL
223
- const emailSubject = config.MAIL_SUBJECT || `${PARENT_NICK},您在『${SITE_NAME}』上的评论收到了回复`
224
- let emailContent
225
- if (config.MAIL_TEMPLATE) {
226
- emailContent = config.MAIL_TEMPLATE
227
- .replace(/\${IMG}/g, IMG)
228
- .replace(/\${PARENT_IMG}/g, PARENT_IMG)
229
- .replace(/\${SITE_URL}/g, SITE_URL)
230
- .replace(/\${SITE_NAME}/g, SITE_NAME)
231
- .replace(/\${PARENT_NICK}/g, PARENT_NICK)
232
- .replace(/\${PARENT_COMMENT}/g, PARENT_COMMENT)
233
- .replace(/\${NICK}/g, NICK)
234
- .replace(/\${COMMENT}/g, COMMENT)
235
- .replace(/\${POST_URL}/g, POST_URL)
236
- } else {
237
- emailContent = `
238
- <div style="border-top:2px solid #12ADDB;box-shadow:0 1px 3px #AAAAAA;line-height:180%;padding:0 15px 12px;margin:50px auto;font-size:12px;">
239
- <h2 style="border-bottom:1px solid #dddddd;font-size:14px;font-weight:normal;padding:13px 0 10px 8px;">
240
- 您在<a style="text-decoration:none;color: #12ADDB;" href="${SITE_URL}" target="_blank">${SITE_NAME}</a>上的评论有了新的回复
241
- </h2>
242
- ${PARENT_NICK} 同学,您曾发表评论:
243
- <div style="padding:0 12px 0 12px;margin-top:18px">
244
- <div style="background-color: #f5f5f5;padding: 10px 15px;margin:18px 0;word-wrap:break-word;">${PARENT_COMMENT}</div>
245
- <p><strong>${NICK}</strong>回复说:</p>
246
- <div style="background-color: #f5f5f5;padding: 10px 15px;margin:18px 0;word-wrap:break-word;">${COMMENT}</div>
247
- <p>
248
- 您可以点击<a style="text-decoration:none; color:#12addb" href="${POST_URL}" target="_blank">查看回复的完整內容</a>,
249
- 欢迎再次光临<a style="text-decoration:none; color:#12addb" href="${SITE_URL}" target="_blank">${SITE_NAME}</a>。<br>
250
- </p>
251
- </div>
252
- </div>`
253
- }
254
- if (!isValidEmail(parentComment.mail)) {
255
- logger.warn('回复通知邮箱格式不合法,跳过发送回复通知:', parentComment.mail)
256
- return
257
- }
258
- let sendResult
259
- try {
260
- sendResult = await transporter.sendMail({
261
- from: `"${config.SENDER_NAME}" <${config.SENDER_EMAIL}>`,
262
- to: parentComment.mail,
263
- subject: emailSubject,
264
- html: emailContent
265
- })
266
- } catch (e) {
267
- sendResult = e
268
- }
269
- logger.log('回复通知结果:', sendResult)
270
- return sendResult
271
- },
272
- appendHashToUrl (url, hash) {
273
- if (url.indexOf('#') === -1) {
274
- return `${url}#${hash}`
275
- } else {
276
- return `${url.substring(0, url.indexOf('#'))}#${hash}`
277
- }
278
- },
279
- async emailTest (event, config, isAdminUser) {
280
- const res = {}
281
- if (isAdminUser) {
282
- try {
283
- // 邮件测试前清除 transporter,保证读取的是最新的配置
284
- transporter = null
285
- await fn.initMailer({ config, throwErr: true })
286
- const toMail = event.mail || config.BLOGGER_EMAIL || config.SENDER_EMAIL
287
- if (!isValidEmail(toMail)) {
288
- res.message = '邮箱格式不合法'
289
- return res
290
- }
291
- const sendResult = await transporter.sendMail({
292
- from: config.SENDER_EMAIL,
293
- to: toMail,
294
- subject: 'Twikoo 邮件通知测试邮件',
295
- html: '如果您收到这封邮件,说明 Twikoo 邮件功能配置正确'
296
- })
297
- res.result = sendResult
298
- } catch (e) {
299
- res.message = e.message
300
- }
301
- } else {
302
- res.code = RES_CODE.NEED_LOGIN
303
- res.message = '请先登录'
304
- }
305
- return res
306
- }
307
- }
308
-
309
- module.exports = fn
package/utils/spam.js DELETED
@@ -1,241 +0,0 @@
1
- const {
2
- getAkismetClient,
3
- getTencentcloudTms
4
- } = require('./lib')
5
- const {
6
- equalsMail
7
- } = require('.')
8
- const AkismetClient = getAkismetClient()
9
-
10
- const logger = require('./logger')
11
-
12
- let tencentcloudTms
13
- let generateTextPromise
14
-
15
- function getTencentCloudTms () {
16
- if (!tencentcloudTms) {
17
- try {
18
- tencentcloudTms = getTencentcloudTms()
19
- } catch (e) {
20
- logger.warn('加载 "tencentcloud-sdk-nodejs-tms" 失败', e)
21
- }
22
- }
23
- return tencentcloudTms
24
- }
25
-
26
- function getGenerateText () {
27
- if (!generateTextPromise) {
28
- generateTextPromise = import('@xsai/generate-text')
29
- .then(({ generateText }) => generateText)
30
- .catch((error) => {
31
- generateTextPromise = null
32
- throw error
33
- })
34
- }
35
- return generateTextPromise
36
- }
37
-
38
- // 提取json结构的函数
39
- function extractJson (rawText) {
40
- if (!rawText) return ''
41
- const trimmed = rawText.trim()
42
- const match = trimmed.match(/\{[\s\S]*\}/)
43
- return match ? match[0] : trimmed
44
- }
45
-
46
- // 移除多余的字符
47
- function repairJson (jsonStr) {
48
- if (!jsonStr) return ''
49
- let cleaned = jsonStr.trim()
50
- cleaned = cleaned.replace(/,\s*([}\]])/g, '$1')
51
- return cleaned
52
- }
53
-
54
- // validateJson 返回的 error 信息会被拼接进 prompt
55
- function validateJson (jsonStr) {
56
- try {
57
- const obj = JSON.parse(jsonStr)
58
- if (typeof obj !== 'object' || obj === null) {
59
- return { valid: false, error: 'Parsed JSON is not an object' }
60
- }
61
- if (!('spam' in obj)) {
62
- return { valid: false, error: 'Missing required key "spam"' }
63
- }
64
- if (typeof obj.spam !== 'boolean') {
65
- return { valid: false, error: 'Key "spam" must be a boolean value' }
66
- }
67
- return { valid: true, data: obj }
68
- } catch (err) {
69
- return { valid: false, error: `JSON Parse failed: ${err.message}` }
70
- }
71
- }
72
-
73
- // 生成提示词的函数: system 为管理员指令,user 为待审核的评论数据
74
- // errorMsg 用于重试,customPrompt 替代默认的 system 指令
75
- function buildMessages (commentData, errorMsg = '', customPrompt = '') {
76
- // 1. system 指令(管理员自定义或内置默认)
77
- const systemContent = customPrompt || `You are a blog comment moderation assistant. Analyze ALL fields below and determine if this submission is spam or ham.
78
-
79
- Spam includes ANY of the following in ANY field:
80
- - Commercial ads, promotions, or buying/selling offers (e.g., "代开发票", "加微信", "兼职", "办证", "AI中转站").
81
- - Special case: If the comment text is harmless, but the nickname is suspicious (e.g., contains ads or promotions) AND a website link is provided, treat it as SPAM.
82
- - Meaningless gibberish or spammy repetition (e.g., "顶顶顶", "111111", "asdfgh", "好" repeated).
83
- - Abusive language, insults, or offensive Chinese slang.
84
- - Suspicious links or SEO spam in the website field.
85
- - Bot-like automated greetings.
86
-
87
- Ham includes:
88
- - Genuine questions, constructive feedback, technical discussions, or normal greetings in Chinese/English.
89
-
90
- Strictly follow these rules:
91
- 1. If ANY field contains spam content, output exactly {"spam": true}.
92
- 2. If ALL fields are legitimate, output exactly {"spam": false}.
93
- 3. Do not include any explanations, introduction, punctuation, or extra spaces. Output only the JSON object.
94
-
95
- Your response MUST be a single valid JSON object, like:
96
- {"spam": true} or {"spam": false}`
97
-
98
- // 2. user 数据(始终是待审核的评论内容)
99
- let userContent = `Comment: ${commentData.comment}
100
- Nickname: ${commentData.nick || ''}
101
- Website: ${commentData.link || ''}`
102
-
103
- // 3. 如果有错误信息,追加到用户消息末尾
104
- if (errorMsg) {
105
- userContent += `\n\n[ERROR FROM PREVIOUS ATTEMPT]: Your last response failed verification with error: "${errorMsg}". Please correct your output format and make sure to return exactly valid JSON.`
106
- }
107
-
108
- // 4. 返回 messages 数组
109
- const finalPrompt = [
110
- { role: 'system', content: systemContent },
111
- { role: 'user', content: userContent }
112
- ]
113
- logger.log('提示词是:', finalPrompt)
114
- return finalPrompt
115
- }
116
-
117
- async function checkByLLM (comment, config) {
118
- const maxRetries = Number(config.LLM_MAX_RETRIES) || 3
119
- let lastError = ''
120
-
121
- // 网络/Provider 异常或者格式校验不通过会进入重试逻辑
122
- for (let attempt = 1; attempt <= maxRetries; attempt++) {
123
- if (attempt > 1) {
124
- await new Promise(resolve => setTimeout(resolve, 1000))
125
- }
126
- try {
127
- let messages
128
- // 自定义提示词和内置提示词的 message 构建逻辑全交给 buildMessages 函数
129
- if (config.LLM_SPAM_PROMPT) {
130
- messages = buildMessages(comment, lastError, config.LLM_SPAM_PROMPT)
131
- } else {
132
- messages = buildMessages(comment, lastError)
133
- }
134
-
135
- const generateText = await getGenerateText()
136
- const chatCompletion = await generateText({
137
- apiKey: config.LLM_API_KEY,
138
- baseURL: config.LLM_API_ENDPOINT || 'https://api.deepseek.com/v1',
139
- model: config.LLM_MODEL || 'deepseek-chat',
140
- responseFormat: { type: 'json_object' },
141
- messages
142
- })
143
-
144
- const rawText = chatCompletion.text || ''
145
-
146
- const extracted = extractJson(rawText)
147
- const repaired = repairJson(extracted)
148
- const validation = validateJson(repaired)
149
-
150
- // 校验通过返回,不通过继续循环
151
- if (validation.valid) {
152
- const isSpam = validation.data.spam
153
- if (isSpam) {
154
- logger.info(`LLM 判定为 SPAM (尝试第 ${attempt} 次): id="${comment.id}" nick="${comment.nick}"`)
155
- } else {
156
- logger.log(`LLM 判定为 HAM (尝试第 ${attempt} 次): id="${comment.id}" nick="${comment.nick}"`)
157
- }
158
- return isSpam
159
- } else {
160
- lastError = validation.error
161
- logger.warn(`LLM 返回校验失败 (尝试第 ${attempt}/${maxRetries} 次), 错误: ${lastError}. 原始返回: "${rawText}"`)
162
- }
163
- } catch (error) {
164
- lastError = error.message
165
- logger.error(`LLM 请求异常 (尝试第 ${attempt}/${maxRetries} 次), 错误: ${lastError}`)
166
- }
167
- }
168
-
169
- logger.error(`LLM 垃圾评论检测历经 ${maxRetries} 次尝试均失败,执行终极放行兜底(返回 false)`)
170
- return false
171
- }
172
-
173
- const fn = {
174
- // 后垃圾评论检测
175
- async postCheckSpam (comment, config) {
176
- try {
177
- let isSpam
178
- if (comment.isSpam) {
179
- // 预检测没过的,就不再检测了
180
- isSpam = true
181
- } else if (equalsMail(config.BLOGGER_EMAIL, comment.mail)) {
182
- // 博主本人评论,不再检测了
183
- isSpam = false
184
- } else if (config.QCLOUD_SECRET_ID && config.QCLOUD_SECRET_KEY) {
185
- // 腾讯云内容安全
186
- const client = new (getTencentCloudTms().tms.v20201229.Client)({
187
- credential: { secretId: config.QCLOUD_SECRET_ID, secretKey: config.QCLOUD_SECRET_KEY },
188
- region: 'ap-shanghai',
189
- profile: { httpProfile: { endpoint: 'tms.tencentcloudapi.com' } }
190
- })
191
- const textModerationParams = {
192
- // 文档: https://cloud.tencent.com/document/api/1124/51860
193
- Content: Buffer.from(comment.comment, 'utf8').toString('base64'),
194
- DataId: comment.id,
195
- User: { Nickname: comment.nick },
196
- Device: { IP: comment.ip }
197
- }
198
- if (config.QCLOUD_CMS_BIZTYPE) {
199
- textModerationParams.BizType = config.QCLOUD_CMS_BIZTYPE
200
- }
201
- logger.log('腾讯云请求参数:', textModerationParams)
202
- const checkResult = await client.TextModeration(textModerationParams)
203
- logger.log('腾讯云返回结果:', checkResult)
204
- isSpam = checkResult.Suggestion !== 'Pass'
205
- if (isSpam) {
206
- logger.warn(`腾讯云判定不通过: id="${comment.id}" nick="${comment.nick}" suggestion="${checkResult.Suggestion}" label="${checkResult.Label}" subLabel="${checkResult.SubLabel}"`)
207
- }
208
- } else if (config.AKISMET_KEY) {
209
- // Akismet
210
- const akismetClient = new AkismetClient({
211
- key: config.AKISMET_KEY,
212
- blog: config.SITE_URL
213
- })
214
- const isValid = await akismetClient.verifyKey()
215
- if (!isValid) {
216
- logger.warn('Akismet key 不可用:', config.AKISMET_KEY)
217
- return
218
- }
219
- isSpam = await akismetClient.checkSpam({
220
- user_ip: comment.ip,
221
- user_agent: comment.ua,
222
- permalink: comment.href,
223
- comment_type: comment.rid ? 'reply' : 'comment',
224
- comment_author: comment.nick,
225
- comment_author_email: comment.mail,
226
- comment_author_url: comment.link,
227
- comment_content: comment.comment
228
- })
229
- } else if (config.LLM_API_KEY) {
230
- // 大语言模型检测
231
- isSpam = await checkByLLM(comment, config)
232
- }
233
- logger.log('垃圾评论检测结果:', isSpam)
234
- return isSpam
235
- } catch (err) {
236
- logger.error('垃圾评论检测异常:', err)
237
- }
238
- }
239
- }
240
-
241
- module.exports = fn