Twikoo评论系统下即时通知推送增加原生纯Onebot_v11协议的支持

Twikoo评论系统下即时通知推送增加原生纯Onebot_v11协议的支持
卖桃子的小女孩前言
因为go-cqhttp已差不多停止维护,go-cqhttp用户推荐大家迁移至无头NTQQ项目苟延残喘。而无头NTQQ的QQ登录协议器项目大部分是通过Onebot 11的API协议,go-cqhttp是扩展过和修改过后实现。其中推送结构有所不同,单纯使用go-cqhttp的推送方式给Onebot 11的正向HTTP会类型报错。故有此pushoo增加fork分支
私有部署和私有部署(docker)方式修改方式与步骤
本次修改替换一个文件,然后编译后再重启Twikoo服务,请注意备份
私有部署修改
第一步 取得你npm安装模块的根路径
- 如果你当时是按Twikoo官方的私有部署方式运行
npm i -g tkserver
形式
则是运行npm root -g
获得你的npm模块路径
获得例如/root/nodejs/v18.19.0/lib/node_modules
(具体按你获得的路径,此仅为示例) - /node_modules下拥有
tkserver
文件夹,此为关键。如未找到tkserver,请回忆分清你当时是npm i -g tkserver
还是npm i tkserver
安装
第二步 修改tkserver下的pushoo下的index.ts
tkserver
文件夹下路径 打开/tkserver/node_modules/pushoo
,运行一遍npm install
- 打开文件
pushoo/src
文件夹下的index.ts
例如/root/nodejs/v18.19.0/lib/node_modules/tkserver/node_modules/pushoo/src/index.ts
- 全部替换代码内容为如下代码,项目地址为Github
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710import axios from 'axios';
import { marked } from 'marked';
import markdownToTxt from 'markdown-to-txt';
export interface NoticeOptions {
/**
* bark通知方式的参数配置
*/
bark?: {
/**
* url 用于点击通知后跳转的地址
*/
url?: string;
};
/**
* IFTTT通知方式的参数配置
*/
ifttt?: {
value1?: string;
value2?: string;
value3?: string;
};
/**
* Discord通知方式的参数配置
*/
discord?: {
userName?: string;
avatarUrl?: string;
};
/**
* WxPusher通知方式的参数配置
*/
wxpusher?: {
uids?: string[];
url?: string;
verifyPay?: boolean;
};
/**
* QMsg酱通知方式的参数配置
*/
qmsg?: {
qq?: string;
url?: string;
group?: boolean;
bot?: string;
};
onebot?: {
/**
* 群号(群发时必填)
*/
group_id?: number;
/**
* QQ号(私聊时必填)
*/
user_id?: number;
/**
* 消息类型(group/private)
*/
message_type?: string;
access_token?: string;
};
dingtalk?: {
/**
* 消息类型,目前支持 text、markdown。不设置,默认为 text。
*/
msgtype?: string;
};
}
export interface CommonOptions {
token: string;
title?: string;
content: string;
/**
* 扩展选项
*/
options?: NoticeOptions;
}
export type ChannelType =
| 'qmsg'
| 'serverchan'
| 'serverchain'
| 'pushplus'
| 'pushplushxtrip'
| 'dingtalk'
| 'wecom'
| 'bark'
| 'gocqhttp'
| 'onebot'
| 'atri'
| 'pushdeer'
| 'igot'
| 'telegram'
| 'feishu'
| 'ifttt'
| 'wecombot'
| 'discord'
| 'wxpusher'
| 'join';
function checkParameters(options: any, requires: string[] = []) {
requires.forEach((require) => {
if (!options[require]) {
throw new Error(`${require} is required`);
}
});
}
function getHtml(content: string) {
return marked.parse(content);
}
function getTxt(content: string) {
return markdownToTxt(content);
}
function getTitle(content: string) {
return getTxt(content).split('\n')[0];
}
function removeUrlAndIp(content: string) {
const urlRegex = /(https?:\/\/[^\s]+)/g;
const ipRegex = /(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})/g;
// 邮箱正则表达式来自 https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input/email#validation
const mailRegExp = /[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*/g;
return content
.replace(urlRegex, '')
.replace(ipRegex, '')
.replace(mailRegExp, '');
}
/**
* https://qmsg.zendee.cn/
*/
async function noticeQmsg(options: CommonOptions) {
checkParameters(options, ['token', 'content']);
const url = options?.options?.qmsg?.url || 'https://qmsg.zendee.cn';
let msg = getTxt(options.content);
if (options.title) {
msg = `${options.title}\n${msg}`;
}
// 移除网址和 IP 以避免 Qmsg 酱被 Tencent 封号
msg = removeUrlAndIp(msg);
const param = new URLSearchParams({ msg });
const qq = options?.options?.qmsg?.qq || false;
if (qq) {
param.append('qq', qq);
}
const bot = options?.options?.qmsg?.bot || false;
if (bot) {
param.append('bot', bot);
}
const group = options?.options?.qmsg?.group || false;
const response = await axios.post(`${url}/${group ? 'group' : 'send'}/${options.token}`, param.toString(), {
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
});
return response.data;
}
/**
* https://github.com/Tianli0/push-bot-api/
*/
async function noticeAtri(options: CommonOptions) {
checkParameters(options, ['token', 'content']);
const url = 'http://pushoo.tianli0.top/';
let message = getTxt(options.content);
if (options.title) {
message = `${options.title}\n${message}`;
}
const param = new URLSearchParams({
user_id: options.token,
message,
});
const response = await axios.post(url, param.toString(), {
headers: { 'X-Requested-By': 'pushoo' },
});
return response.data;
}
/**
* Turbo: https://sct.ftqq.com/
* V3: https://sc3.ft07.com/
*/
async function noticeServerChan(options: CommonOptions) {
checkParameters(options, ['token', 'content']);
let url: string;
let param: URLSearchParams;
if (options.token.startsWith('sctp')) {
url = `https://${options.token.match(/^sctp(\d+)t/)[1]}.push.ft07.com/send`;
param = new URLSearchParams({
title: options.title || getTitle(options.content),
desp: options.content,
});
} else if (options.token.substring(0, 3).toLowerCase() === 'sct') {
url = 'https://sctapi.ftqq.com';
param = new URLSearchParams({
title: options.title || getTitle(options.content),
desp: options.content,
});
} else {
url = 'https://sc.ftqq.com';
param = new URLSearchParams({
text: options.title || getTitle(options.content),
desp: options.content,
});
}
const response = await axios.post(`${url}/${options.token}.send`, param.toString(), {
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
});
return response.data;
}
/**
* https://www.pushplus.plus/
*/
async function noticePushPlus(options: CommonOptions) {
checkParameters(options, ['token', 'content']);
const ppApiUrl = 'http://www.pushplus.plus/send';
const ppApiParam = {
token: options.token,
title: options.title || getTitle(options.content),
content: options.content,
template: 'markdown',
};
const response = await axios.post(ppApiUrl, ppApiParam);
return response.data;
}
/**
* https://pushplus.hxtrip.com/
*/
async function noticePushPlusHxtrip(options: CommonOptions) {
checkParameters(options, ['token', 'content']);
const ppApiUrl = 'http://pushplus.hxtrip.com/send';
const ppApiParam = {
token: options.token,
title: options.title || getTitle(options.content),
content: getHtml(options.content),
template: 'html',
};
const response = await axios.post(ppApiUrl, ppApiParam);
return response.data;
}
/**
* 文档: https://open.dingtalk.com/document/group/custom-robot-access
* 教程: https://blog.ljcbaby.top/article/Twikoo-DingTalk/
*/
async function noticeDingTalk(options: CommonOptions) {
checkParameters(options, ['token', 'content']);
let url = 'https://oapi.dingtalk.com/robot/send?access_token=';
if (options.token.substring(0, 4).toLowerCase() === 'http') {
url = options.token;
} else {
url += options.token;
}
const msgtype = options.options?.dingtalk?.msgtype || 'text';
const content = msgtype === 'text'
? (options.title ? `${options.title}\n` : '') + getTxt(options.content)
: options.content;
const msgBody = {
msgtype,
};
if (msgtype === 'text') {
msgBody[msgtype] = { content };
} else if (msgtype === 'markdown') {
msgBody[msgtype] = { title: options.title || getTitle(options.content), text: content };
}
const response = await axios.post(url, msgBody);
return response.data;
}
/**
* 文档: https://developer.work.weixin.qq.com/document/path/90236
* 教程: https://sct.ftqq.com/forward
*/
async function noticeWeCom(options: CommonOptions) {
checkParameters(options, ['token', 'content']);
const [corpid, corpsecret, agentid, touser = '@all'] = options.token.split('#');
checkParameters(
{
corpid,
corpsecret,
agentid,
},
['corpid', 'corpsecret', 'agentid'],
);
// 获取 Access Token
let accessToken;
try {
const accessTokenRes = await axios.get(
`https://qyapi.weixin.qq.com/cgi-bin/gettoken?corpid=${corpid}&corpsecret=${corpsecret}`,
);
accessToken = accessTokenRes.data.access_token;
} catch (e) {
console.error('获取企业微信 access token 失败,请检查 token', e);
return {};
}
// 发送消息
const url = `https://qyapi.weixin.qq.com/cgi-bin/message/send?access_token=${accessToken}`;
let content = getTxt(options.content);
if (options.title) {
content = `${options.title}\n${content}`;
}
const param = {
touser,
msgtype: 'text',
agentid,
text: { content },
};
const response = await axios.post(url, param);
return response.data;
}
/**
* https://github.com/Finb/Bark
*/
async function noticeBark(options: CommonOptions) {
checkParameters(options, ['token', 'content']);
let url = 'https://api.day.app/';
if (options.token.substring(0, 4).toLowerCase() === 'http') {
url = options.token;
} else {
url += options.token;
}
if (!url.endsWith('/')) url += '/';
const title = encodeURIComponent(options.title || getTitle(options.content));
const content = encodeURIComponent(getTxt(options.content));
const params = new URLSearchParams({
url: options?.options?.bark?.url || '',
});
const response = await axios.get(`${url}${title}/${content}/`, { params });
return response.data;
}
/**
* 文档: https://docs.go-cqhttp.org/api/
* 教程: https://twikoo.js.org/QQ_API.html
*/
async function noticeGoCqhttp(options: CommonOptions) {
checkParameters(options, ['token', 'content']);
const url = options.token;
let message = getTxt(options.content);
if (options.title) {
message = `${options.title}\n${message}`;
}
const param = new URLSearchParams({ message });
const response = await axios.post(url, param.toString());
return response.data;
}
/**
* 文档: https://github.com/botuniverse/onebot-11
* 教程: https://ayakasuki.com/
*/
async function noticeNodeOnebot(options: CommonOptions) {
checkParameters(options, ['token', 'content']);
try {
// 1. 解析完整URL(包含action和参数)
const fullUrl = options.token;
const urlObj = new URL(fullUrl);
const baseUrl = `${urlObj.protocol}//${urlObj.host}`;
// 2. 从URL路径提取action类型
const actionPath = urlObj.pathname.split('/').pop() || '';
let action: string;
// 自动识别动作类型(群发/私聊)
if (actionPath.includes('group')) {
action = 'send_group_msg';
} else if (actionPath.includes('private')) {
action = 'send_private_msg';
} else {
action = actionPath; // 保留原始action
}
// 3. 从URL查询参数获取关键数据
const urlParams = new URLSearchParams(urlObj.search);
const accessToken = urlParams.get('access_token') || '';
const groupId = urlParams.get('group_id');
const userId = urlParams.get('user_id');
// 4. 构建消息参数(优先级:URL参数 > 配置参数)
const params: Record<string, any> = {
message: options.title
? `${options.title}\n${getTxt(options.content)}`
: getTxt(options.content)
};
// 根据参数类型设置目标
if (groupId) {
params.group_id = Number(groupId);
} else if (userId) {
params.user_id = Number(userId);
} else if (options?.options?.onebot?.group_id) {
params.group_id = Number(options.options.onebot.group_id);
} else if (options?.options?.onebot?.user_id) {
params.user_id = Number(options.options.onebot.user_id);
} else {
throw new Error('OneBot 必须提供 group_id 或 user_id');
}
// 5. 构建最终请求URL(保留原始路径结构)
const apiUrl = `${baseUrl}/${actionPath}`;
// 6. 发送HTTP请求
const response = await axios.post(apiUrl, params, {
timeout: 5000,
headers: {
'Content-Type': 'application/json',
...(accessToken ? { Authorization: `Bearer ${accessToken}` } : {})
}
});
// 7. 处理OneBot响应
if (response.data?.retcode !== 0) {
throw new Error(`[${response.data.retcode}] ${response.data.message}`);
}
return response.data;
} catch (e) {
// 增强错误日志(包含原始URL)
console.error('[ONEBOT] 请求失败:', {
originalUrl: options.token,
error: e.response?.data || e.message
});
throw new Error(`OneBot推送失败: ${e.message}`);
}
}
async function noticePushdeer(options: CommonOptions) {
checkParameters(options, ['token', 'content']);
const url = 'https://api2.pushdeer.com/message/push';
const response = await axios.post(url, {
pushkey: options.token,
text: options.title || getTitle(options.content),
desp: options.content,
});
return response.data;
}
async function noticeIgot(options: CommonOptions) {
checkParameters(options, ['token', 'content']);
const url = `https://push.hellyw.com/${options.token}`;
const response = await axios.post(url, {
title: options.title || getTitle(options.content),
content: getTxt(options.content),
});
return response.data;
}
/**
* 文档: https://core.telegram.org/method/messages.sendMessage
* 教程: https://core.telegram.org/bots#3-how-do-i-create-a-bot
*/
async function noticeTelegram(options: CommonOptions) {
checkParameters(options, ['token', 'content']);
const [tgToken, chatId] = options.token.split('#');
checkParameters(
{
tgToken,
chatId,
},
['tgToken', 'chatId'],
);
let text = options.content.replace(/([*_])/g, '\\$1'); // * 和 _ 似乎需要转义,否则会抛出 400 Bad Request 以及消息显示不正常
if (options.title) {
text = `${options.title}\n\n${text}`;
}
const response = await axios.post(`https://api.telegram.org/bot${tgToken}/sendMessage`, {
text,
chat_id: chatId,
parse_mode: 'Markdown',
});
return response.data;
}
/**
* https://www.feishu.cn/hc/zh-CN/articles/360024984973
*/
async function noticeFeishu(options: CommonOptions) {
checkParameters(options, ['token', 'content']);
const v1 = 'https://open.feishu.cn/open-apis/bot/hook/';
const v2 = 'https://open.feishu.cn/open-apis/bot/v2/hook/';
let url;
let params;
if (options.token.substring(0, 4).toLowerCase() === 'http') {
url = options.token;
} else {
url = v2 + options.token;
}
if (url.substring(0, v1.length) === v1) {
params = {
title: options.title || getTitle(options.content),
text: getTxt(options.content),
};
} else {
let text = getTxt(options.content);
if (options.title) {
text = `${options.title}\n${text}`;
}
params = {
msg_type: 'text',
content: { text },
};
}
const response = await axios.post(url, params);
return response.data;
}
/**
* https://ifttt.com/maker_webhooks
* http://ift.tt/webhooks_faq
*/
async function noticeIfttt(options: CommonOptions) {
checkParameters(options, ['token', 'content']);
const [token, eventName] = options.token.split('#');
checkParameters(
{
token,
eventName,
},
['token', 'eventName'],
);
const url = `https://maker.ifttt.com/trigger/${eventName}/with/key/${token}`;
const response = await axios.post(
url,
{
value1: options.options?.ifttt?.value1 || getTxt(options.title),
value2: options.options?.ifttt?.value2 || getTxt(options.content),
value3: options.options?.ifttt?.value3,
},
{
headers: { 'Content-Type': 'application/json' },
},
);
return response.data;
}
/**
* 文档: https://developer.work.weixin.qq.com/document/path/91770
* 教程: https://developer.work.weixin.qq.com/tutorial/detail/54
*/
async function noticeWecombot(options: CommonOptions) {
checkParameters(options, ['token', 'content']);
const url = `https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=${options.token}`;
const content = getTxt(options.content);
const response = await axios.post(
url,
{
msgtype: 'text',
text: {
content,
},
},
{
headers: { 'Content-Type': 'application/json' },
},
);
return response.data;
}
/**
* 文档:https://discord.com/developers/docs/resources/webhook#execute-webhook
*/
async function noticeDiscord(options: CommonOptions) {
checkParameters(options, ['token', 'content']);
const url = options.token.startsWith('https://')
? options.token
: `https://discord.com/api/webhooks/${options.token.replace(/#/, '/')}`;
const response = await axios.post(
url,
{
content: options.content,
username: options.options?.discord?.userName,
avatar_url: options.options?.discord?.avatarUrl,
},
{
headers: { 'Content-Type': 'application/json' },
},
);
return `Delivered successfully, code ${response.status}.`;
}
/**
* WXPusher 推送
* 教程:https://wxpusher.zjiecode.com/admin/
* 文档: https://wxpusher.zjiecode.com/docs/#/
*/
async function noticeWxPusher(options: CommonOptions) {
checkParameters(options, ['token', 'content']);
const url = 'http://wxpusher.zjiecode.com/api/send/message';
const [appToken, topicIds] = options.token.split('#');
checkParameters({ appToken, topicIds }, ['appToken', 'topicIds']);
const response = await axios.post(
url,
{
appToken,
content: options.content,
summary: options.title || getTitle(options.content),
contentType: 3,
topicIds: topicIds.split(',').map((id) => Number(id)),
uids: options?.options?.wxpusher?.uids || [],
url: options?.options?.wxpusher?.url || '',
verifyPayload: options?.options?.wxpusher?.verifyPay || false,
},
{
headers: {
'Content-Type': 'application/json',
},
},
);
return response.data;
}
/**
* Join 推送
* 文档: https://joaoapps.com/join/api/
*/
async function noticeJoin(options: CommonOptions) {
checkParameters(options, ['token', 'content']);
const [apiKey, deviceId] = options.token.split('#');
checkParameters({ apiKey, deviceId }, ['apiKey', 'deviceId']);
const url = 'https://joinjoaomgcd.appspot.com/_ah/api/messaging/v1/sendPush';
const param = new URLSearchParams({
apikey: apiKey,
deviceId,
title: options.title || getTitle(options.content),
text: options.content,
});
const response = await axios.post(url, param.toString(), {
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
});
return response.data;
}
async function notice(channel: ChannelType, options: CommonOptions) {
try {
let data: any;
const noticeFn = {
qmsg: noticeQmsg,
serverchan: noticeServerChan,
serverchain: noticeServerChan,
pushplus: noticePushPlus,
pushplushxtrip: noticePushPlusHxtrip,
dingtalk: noticeDingTalk,
wecom: noticeWeCom,
bark: noticeBark,
gocqhttp: noticeGoCqhttp,
onebot:noticeNodeOnebot,
atri: noticeAtri,
pushdeer: noticePushdeer,
igot: noticeIgot,
telegram: noticeTelegram,
feishu: noticeFeishu,
ifttt: noticeIfttt,
wecombot: noticeWecombot,
discord: noticeDiscord,
wxpusher: noticeWxPusher,
join: noticeJoin,
}[channel.toLowerCase()];
if (noticeFn) {
data = await noticeFn(options);
} else {
throw new Error(`<${channel}> is not supported`);
}
console.debug(`[PUSHOO] Send to <${channel}> result:`, data);
return data;
} catch (e) {
console.error('[PUSHOO] Got error:', e.message);
return { error: e };
}
}
export default notice;
export {
notice,
noticeQmsg,
noticeServerChan,
noticePushPlus,
noticePushPlusHxtrip,
noticeDingTalk,
noticeWeCom,
noticeBark,
noticeGoCqhttp,
noticeNodeOnebot,
noticeAtri,
noticePushdeer,
noticeIgot,
noticeTelegram,
noticeFeishu,
noticeIfttt,
noticeWecombot,
noticeDiscord,
noticeWxPusher,
noticeJoin,
}; - 替换好后回退至
/root/nodejs/v18.19.0/lib/node_modules/tkserver/node_modules/pushoo
- 运行
npm run build
,其中无出错即为完成
私有部署(docker)修改方式和步骤
前言
其中Docker 需要映射容器内的/app路径内容为例如
-v /root/twikoo/app:/app
。官方是少了这一个,只映射了-v ${PWD}/data:/app/data
。这样我们没办法映射出容器项目内的_data\node_modules\pushoo
文件夹的内容的。所以如果你没有映射,推荐重新docker run 加上-v /root/twikoo/app:/app
这一部分。如果是可视化docker那就更方便,只加这一部分重启容器即可。
在前言的条件已满足的情况下
第一步 获取pushoo的文件夹并修改其文件
- 假设映射目录为
/root/twikoo/app
则前往/root/twikoo/app/_data/node_modules/pushoo/src
- 修改
src
下的index.ts
内容为1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710import axios from 'axios';
import { marked } from 'marked';
import markdownToTxt from 'markdown-to-txt';
export interface NoticeOptions {
/**
* bark通知方式的参数配置
*/
bark?: {
/**
* url 用于点击通知后跳转的地址
*/
url?: string;
};
/**
* IFTTT通知方式的参数配置
*/
ifttt?: {
value1?: string;
value2?: string;
value3?: string;
};
/**
* Discord通知方式的参数配置
*/
discord?: {
userName?: string;
avatarUrl?: string;
};
/**
* WxPusher通知方式的参数配置
*/
wxpusher?: {
uids?: string[];
url?: string;
verifyPay?: boolean;
};
/**
* QMsg酱通知方式的参数配置
*/
qmsg?: {
qq?: string;
url?: string;
group?: boolean;
bot?: string;
};
onebot?: {
/**
* 群号(群发时必填)
*/
group_id?: number;
/**
* QQ号(私聊时必填)
*/
user_id?: number;
/**
* 消息类型(group/private)
*/
message_type?: string;
access_token?: string;
};
dingtalk?: {
/**
* 消息类型,目前支持 text、markdown。不设置,默认为 text。
*/
msgtype?: string;
};
}
export interface CommonOptions {
token: string;
title?: string;
content: string;
/**
* 扩展选项
*/
options?: NoticeOptions;
}
export type ChannelType =
| 'qmsg'
| 'serverchan'
| 'serverchain'
| 'pushplus'
| 'pushplushxtrip'
| 'dingtalk'
| 'wecom'
| 'bark'
| 'gocqhttp'
| 'onebot'
| 'atri'
| 'pushdeer'
| 'igot'
| 'telegram'
| 'feishu'
| 'ifttt'
| 'wecombot'
| 'discord'
| 'wxpusher'
| 'join';
function checkParameters(options: any, requires: string[] = []) {
requires.forEach((require) => {
if (!options[require]) {
throw new Error(`${require} is required`);
}
});
}
function getHtml(content: string) {
return marked.parse(content);
}
function getTxt(content: string) {
return markdownToTxt(content);
}
function getTitle(content: string) {
return getTxt(content).split('\n')[0];
}
function removeUrlAndIp(content: string) {
const urlRegex = /(https?:\/\/[^\s]+)/g;
const ipRegex = /(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})/g;
// 邮箱正则表达式来自 https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input/email#validation
const mailRegExp = /[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*/g;
return content
.replace(urlRegex, '')
.replace(ipRegex, '')
.replace(mailRegExp, '');
}
/**
* https://qmsg.zendee.cn/
*/
async function noticeQmsg(options: CommonOptions) {
checkParameters(options, ['token', 'content']);
const url = options?.options?.qmsg?.url || 'https://qmsg.zendee.cn';
let msg = getTxt(options.content);
if (options.title) {
msg = `${options.title}\n${msg}`;
}
// 移除网址和 IP 以避免 Qmsg 酱被 Tencent 封号
msg = removeUrlAndIp(msg);
const param = new URLSearchParams({ msg });
const qq = options?.options?.qmsg?.qq || false;
if (qq) {
param.append('qq', qq);
}
const bot = options?.options?.qmsg?.bot || false;
if (bot) {
param.append('bot', bot);
}
const group = options?.options?.qmsg?.group || false;
const response = await axios.post(`${url}/${group ? 'group' : 'send'}/${options.token}`, param.toString(), {
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
});
return response.data;
}
/**
* https://github.com/Tianli0/push-bot-api/
*/
async function noticeAtri(options: CommonOptions) {
checkParameters(options, ['token', 'content']);
const url = 'http://pushoo.tianli0.top/';
let message = getTxt(options.content);
if (options.title) {
message = `${options.title}\n${message}`;
}
const param = new URLSearchParams({
user_id: options.token,
message,
});
const response = await axios.post(url, param.toString(), {
headers: { 'X-Requested-By': 'pushoo' },
});
return response.data;
}
/**
* Turbo: https://sct.ftqq.com/
* V3: https://sc3.ft07.com/
*/
async function noticeServerChan(options: CommonOptions) {
checkParameters(options, ['token', 'content']);
let url: string;
let param: URLSearchParams;
if (options.token.startsWith('sctp')) {
url = `https://${options.token.match(/^sctp(\d+)t/)[1]}.push.ft07.com/send`;
param = new URLSearchParams({
title: options.title || getTitle(options.content),
desp: options.content,
});
} else if (options.token.substring(0, 3).toLowerCase() === 'sct') {
url = 'https://sctapi.ftqq.com';
param = new URLSearchParams({
title: options.title || getTitle(options.content),
desp: options.content,
});
} else {
url = 'https://sc.ftqq.com';
param = new URLSearchParams({
text: options.title || getTitle(options.content),
desp: options.content,
});
}
const response = await axios.post(`${url}/${options.token}.send`, param.toString(), {
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
});
return response.data;
}
/**
* https://www.pushplus.plus/
*/
async function noticePushPlus(options: CommonOptions) {
checkParameters(options, ['token', 'content']);
const ppApiUrl = 'http://www.pushplus.plus/send';
const ppApiParam = {
token: options.token,
title: options.title || getTitle(options.content),
content: options.content,
template: 'markdown',
};
const response = await axios.post(ppApiUrl, ppApiParam);
return response.data;
}
/**
* https://pushplus.hxtrip.com/
*/
async function noticePushPlusHxtrip(options: CommonOptions) {
checkParameters(options, ['token', 'content']);
const ppApiUrl = 'http://pushplus.hxtrip.com/send';
const ppApiParam = {
token: options.token,
title: options.title || getTitle(options.content),
content: getHtml(options.content),
template: 'html',
};
const response = await axios.post(ppApiUrl, ppApiParam);
return response.data;
}
/**
* 文档: https://open.dingtalk.com/document/group/custom-robot-access
* 教程: https://blog.ljcbaby.top/article/Twikoo-DingTalk/
*/
async function noticeDingTalk(options: CommonOptions) {
checkParameters(options, ['token', 'content']);
let url = 'https://oapi.dingtalk.com/robot/send?access_token=';
if (options.token.substring(0, 4).toLowerCase() === 'http') {
url = options.token;
} else {
url += options.token;
}
const msgtype = options.options?.dingtalk?.msgtype || 'text';
const content = msgtype === 'text'
? (options.title ? `${options.title}\n` : '') + getTxt(options.content)
: options.content;
const msgBody = {
msgtype,
};
if (msgtype === 'text') {
msgBody[msgtype] = { content };
} else if (msgtype === 'markdown') {
msgBody[msgtype] = { title: options.title || getTitle(options.content), text: content };
}
const response = await axios.post(url, msgBody);
return response.data;
}
/**
* 文档: https://developer.work.weixin.qq.com/document/path/90236
* 教程: https://sct.ftqq.com/forward
*/
async function noticeWeCom(options: CommonOptions) {
checkParameters(options, ['token', 'content']);
const [corpid, corpsecret, agentid, touser = '@all'] = options.token.split('#');
checkParameters(
{
corpid,
corpsecret,
agentid,
},
['corpid', 'corpsecret', 'agentid'],
);
// 获取 Access Token
let accessToken;
try {
const accessTokenRes = await axios.get(
`https://qyapi.weixin.qq.com/cgi-bin/gettoken?corpid=${corpid}&corpsecret=${corpsecret}`,
);
accessToken = accessTokenRes.data.access_token;
} catch (e) {
console.error('获取企业微信 access token 失败,请检查 token', e);
return {};
}
// 发送消息
const url = `https://qyapi.weixin.qq.com/cgi-bin/message/send?access_token=${accessToken}`;
let content = getTxt(options.content);
if (options.title) {
content = `${options.title}\n${content}`;
}
const param = {
touser,
msgtype: 'text',
agentid,
text: { content },
};
const response = await axios.post(url, param);
return response.data;
}
/**
* https://github.com/Finb/Bark
*/
async function noticeBark(options: CommonOptions) {
checkParameters(options, ['token', 'content']);
let url = 'https://api.day.app/';
if (options.token.substring(0, 4).toLowerCase() === 'http') {
url = options.token;
} else {
url += options.token;
}
if (!url.endsWith('/')) url += '/';
const title = encodeURIComponent(options.title || getTitle(options.content));
const content = encodeURIComponent(getTxt(options.content));
const params = new URLSearchParams({
url: options?.options?.bark?.url || '',
});
const response = await axios.get(`${url}${title}/${content}/`, { params });
return response.data;
}
/**
* 文档: https://docs.go-cqhttp.org/api/
* 教程: https://twikoo.js.org/QQ_API.html
*/
async function noticeGoCqhttp(options: CommonOptions) {
checkParameters(options, ['token', 'content']);
const url = options.token;
let message = getTxt(options.content);
if (options.title) {
message = `${options.title}\n${message}`;
}
const param = new URLSearchParams({ message });
const response = await axios.post(url, param.toString());
return response.data;
}
/**
* 文档: https://github.com/botuniverse/onebot-11
* 教程: https://ayakasuki.com/
*/
async function noticeNodeOnebot(options: CommonOptions) {
checkParameters(options, ['token', 'content']);
try {
// 1. 解析完整URL(包含action和参数)
const fullUrl = options.token;
const urlObj = new URL(fullUrl);
const baseUrl = `${urlObj.protocol}//${urlObj.host}`;
// 2. 从URL路径提取action类型
const actionPath = urlObj.pathname.split('/').pop() || '';
let action: string;
// 自动识别动作类型(群发/私聊)
if (actionPath.includes('group')) {
action = 'send_group_msg';
} else if (actionPath.includes('private')) {
action = 'send_private_msg';
} else {
action = actionPath; // 保留原始action
}
// 3. 从URL查询参数获取关键数据
const urlParams = new URLSearchParams(urlObj.search);
const accessToken = urlParams.get('access_token') || '';
const groupId = urlParams.get('group_id');
const userId = urlParams.get('user_id');
// 4. 构建消息参数(优先级:URL参数 > 配置参数)
const params: Record<string, any> = {
message: options.title
? `${options.title}\n${getTxt(options.content)}`
: getTxt(options.content)
};
// 根据参数类型设置目标
if (groupId) {
params.group_id = Number(groupId);
} else if (userId) {
params.user_id = Number(userId);
} else if (options?.options?.onebot?.group_id) {
params.group_id = Number(options.options.onebot.group_id);
} else if (options?.options?.onebot?.user_id) {
params.user_id = Number(options.options.onebot.user_id);
} else {
throw new Error('OneBot 必须提供 group_id 或 user_id');
}
// 5. 构建最终请求URL(保留原始路径结构)
const apiUrl = `${baseUrl}/${actionPath}`;
// 6. 发送HTTP请求
const response = await axios.post(apiUrl, params, {
timeout: 5000,
headers: {
'Content-Type': 'application/json',
...(accessToken ? { Authorization: `Bearer ${accessToken}` } : {})
}
});
// 7. 处理OneBot响应
if (response.data?.retcode !== 0) {
throw new Error(`[${response.data.retcode}] ${response.data.message}`);
}
return response.data;
} catch (e) {
// 增强错误日志(包含原始URL)
console.error('[ONEBOT] 请求失败:', {
originalUrl: options.token,
error: e.response?.data || e.message
});
throw new Error(`OneBot推送失败: ${e.message}`);
}
}
async function noticePushdeer(options: CommonOptions) {
checkParameters(options, ['token', 'content']);
const url = 'https://api2.pushdeer.com/message/push';
const response = await axios.post(url, {
pushkey: options.token,
text: options.title || getTitle(options.content),
desp: options.content,
});
return response.data;
}
async function noticeIgot(options: CommonOptions) {
checkParameters(options, ['token', 'content']);
const url = `https://push.hellyw.com/${options.token}`;
const response = await axios.post(url, {
title: options.title || getTitle(options.content),
content: getTxt(options.content),
});
return response.data;
}
/**
* 文档: https://core.telegram.org/method/messages.sendMessage
* 教程: https://core.telegram.org/bots#3-how-do-i-create-a-bot
*/
async function noticeTelegram(options: CommonOptions) {
checkParameters(options, ['token', 'content']);
const [tgToken, chatId] = options.token.split('#');
checkParameters(
{
tgToken,
chatId,
},
['tgToken', 'chatId'],
);
let text = options.content.replace(/([*_])/g, '\\$1'); // * 和 _ 似乎需要转义,否则会抛出 400 Bad Request 以及消息显示不正常
if (options.title) {
text = `${options.title}\n\n${text}`;
}
const response = await axios.post(`https://api.telegram.org/bot${tgToken}/sendMessage`, {
text,
chat_id: chatId,
parse_mode: 'Markdown',
});
return response.data;
}
/**
* https://www.feishu.cn/hc/zh-CN/articles/360024984973
*/
async function noticeFeishu(options: CommonOptions) {
checkParameters(options, ['token', 'content']);
const v1 = 'https://open.feishu.cn/open-apis/bot/hook/';
const v2 = 'https://open.feishu.cn/open-apis/bot/v2/hook/';
let url;
let params;
if (options.token.substring(0, 4).toLowerCase() === 'http') {
url = options.token;
} else {
url = v2 + options.token;
}
if (url.substring(0, v1.length) === v1) {
params = {
title: options.title || getTitle(options.content),
text: getTxt(options.content),
};
} else {
let text = getTxt(options.content);
if (options.title) {
text = `${options.title}\n${text}`;
}
params = {
msg_type: 'text',
content: { text },
};
}
const response = await axios.post(url, params);
return response.data;
}
/**
* https://ifttt.com/maker_webhooks
* http://ift.tt/webhooks_faq
*/
async function noticeIfttt(options: CommonOptions) {
checkParameters(options, ['token', 'content']);
const [token, eventName] = options.token.split('#');
checkParameters(
{
token,
eventName,
},
['token', 'eventName'],
);
const url = `https://maker.ifttt.com/trigger/${eventName}/with/key/${token}`;
const response = await axios.post(
url,
{
value1: options.options?.ifttt?.value1 || getTxt(options.title),
value2: options.options?.ifttt?.value2 || getTxt(options.content),
value3: options.options?.ifttt?.value3,
},
{
headers: { 'Content-Type': 'application/json' },
},
);
return response.data;
}
/**
* 文档: https://developer.work.weixin.qq.com/document/path/91770
* 教程: https://developer.work.weixin.qq.com/tutorial/detail/54
*/
async function noticeWecombot(options: CommonOptions) {
checkParameters(options, ['token', 'content']);
const url = `https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=${options.token}`;
const content = getTxt(options.content);
const response = await axios.post(
url,
{
msgtype: 'text',
text: {
content,
},
},
{
headers: { 'Content-Type': 'application/json' },
},
);
return response.data;
}
/**
* 文档:https://discord.com/developers/docs/resources/webhook#execute-webhook
*/
async function noticeDiscord(options: CommonOptions) {
checkParameters(options, ['token', 'content']);
const url = options.token.startsWith('https://')
? options.token
: `https://discord.com/api/webhooks/${options.token.replace(/#/, '/')}`;
const response = await axios.post(
url,
{
content: options.content,
username: options.options?.discord?.userName,
avatar_url: options.options?.discord?.avatarUrl,
},
{
headers: { 'Content-Type': 'application/json' },
},
);
return `Delivered successfully, code ${response.status}.`;
}
/**
* WXPusher 推送
* 教程:https://wxpusher.zjiecode.com/admin/
* 文档: https://wxpusher.zjiecode.com/docs/#/
*/
async function noticeWxPusher(options: CommonOptions) {
checkParameters(options, ['token', 'content']);
const url = 'http://wxpusher.zjiecode.com/api/send/message';
const [appToken, topicIds] = options.token.split('#');
checkParameters({ appToken, topicIds }, ['appToken', 'topicIds']);
const response = await axios.post(
url,
{
appToken,
content: options.content,
summary: options.title || getTitle(options.content),
contentType: 3,
topicIds: topicIds.split(',').map((id) => Number(id)),
uids: options?.options?.wxpusher?.uids || [],
url: options?.options?.wxpusher?.url || '',
verifyPayload: options?.options?.wxpusher?.verifyPay || false,
},
{
headers: {
'Content-Type': 'application/json',
},
},
);
return response.data;
}
/**
* Join 推送
* 文档: https://joaoapps.com/join/api/
*/
async function noticeJoin(options: CommonOptions) {
checkParameters(options, ['token', 'content']);
const [apiKey, deviceId] = options.token.split('#');
checkParameters({ apiKey, deviceId }, ['apiKey', 'deviceId']);
const url = 'https://joinjoaomgcd.appspot.com/_ah/api/messaging/v1/sendPush';
const param = new URLSearchParams({
apikey: apiKey,
deviceId,
title: options.title || getTitle(options.content),
text: options.content,
});
const response = await axios.post(url, param.toString(), {
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
});
return response.data;
}
async function notice(channel: ChannelType, options: CommonOptions) {
try {
let data: any;
const noticeFn = {
qmsg: noticeQmsg,
serverchan: noticeServerChan,
serverchain: noticeServerChan,
pushplus: noticePushPlus,
pushplushxtrip: noticePushPlusHxtrip,
dingtalk: noticeDingTalk,
wecom: noticeWeCom,
bark: noticeBark,
gocqhttp: noticeGoCqhttp,
onebot:noticeNodeOnebot,
atri: noticeAtri,
pushdeer: noticePushdeer,
igot: noticeIgot,
telegram: noticeTelegram,
feishu: noticeFeishu,
ifttt: noticeIfttt,
wecombot: noticeWecombot,
discord: noticeDiscord,
wxpusher: noticeWxPusher,
join: noticeJoin,
}[channel.toLowerCase()];
if (noticeFn) {
data = await noticeFn(options);
} else {
throw new Error(`<${channel}> is not supported`);
}
console.debug(`[PUSHOO] Send to <${channel}> result:`, data);
return data;
} catch (e) {
console.error('[PUSHOO] Got error:', e.message);
return { error: e };
}
}
export default notice;
export {
notice,
noticeQmsg,
noticeServerChan,
noticePushPlus,
noticePushPlusHxtrip,
noticeDingTalk,
noticeWeCom,
noticeBark,
noticeGoCqhttp,
noticeNodeOnebot,
noticeAtri,
noticePushdeer,
noticeIgot,
noticeTelegram,
noticeFeishu,
noticeIfttt,
noticeWecombot,
noticeDiscord,
noticeWxPusher,
noticeJoin,
};
第二步 回退到pushoo目录
- 回退到
/root/twikoo/app/_data/node_modules/pushoo
,依次运行以下命令即可
1 | npm install |
1 | npm run build |
- 如果显示无出错例如下图
则代表完成,重启你的twikoo的docker容器即可
其他部署说明
其他例如vercel和Hugging Face以及等等其他部署,如果你有条件能够访问器
tkserver
文件夹下的node_modules
文件夹里的pushoo
,你就完全可以参照以上私有部署的步骤参考进行更改代码后进行安装环境npm install
和npm run build
编译替代完成重启。但如果你的环境不支持在里面编译,你也完全可以在有node环境下拷贝pushoo目录下的所有文件出来,进行
npm install
和npm run build
然后拷贝新生成的/pushoo/dist的目录覆盖原有的/pushoo/dist。其实有更好的解决方法就是,我已经pr给pushoo官方,如果他们愿意合并代码,那就更新最新的twikoo坐等即可。希望各位能顺利~
使用说明
上面聊好了如何部署等方法,那么如何引用使用该
onebot 11
协议呢?
该onebot 11
协议在Twikoo前端引入只支持正向HTTP通信,因为仅为推送通知而不是连接机器人。
- 其实格式如下
- 在
pushoo_channel
选项填入onebot
Pushoo_token
处填以下格式内容的url
- (发送到某个QQ号)
http://你的IP或域名:端口号/send_private_msg?user_id=QQ号&access_token=你配置的token
(QQ号) - (发送到某个QQ群)
http://你的IP或域名:端口号/send_group_msg?group_id=群号&access_token=你配置的鉴权 token
(QQ群) - 具体的各种API接口请移步官方文档 OneBot 11官方文档
- (发送到某个QQ号)