qfish 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +393 -3
- package/assets/fonts/OFL.txt +96 -0
- package/assets/fonts/fusion-pixel-12px.woff2 +0 -0
- package/assets/logo.png +0 -0
- package/client.js +156 -0
- package/cordis.patch.yml +15 -0
- package/defaults/banned.json +1 -0
- package/defaults/persona/1-persona.md +7 -0
- package/defaults/persona/2-rules.md +22 -0
- package/defaults/persona/4-sticker.md +8 -0
- package/defaults/persona/5-extra.md +31 -0
- package/defaults/persona/6-misc.md +78 -0
- package/defaults/settings.json +24 -0
- package/defaults/slang.json +1 -0
- package/icon.svg +9 -0
- package/lib/console.js +2705 -0
- package/lib/index.js +6224 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +73 -3
package/lib/console.js
ADDED
|
@@ -0,0 +1,2705 @@
|
|
|
1
|
+
import { createServer } from "node:http";
|
|
2
|
+
import net from "node:net";
|
|
3
|
+
import { readFileSync, writeFileSync, readdirSync, unlinkSync, existsSync, mkdirSync, statSync } from "node:fs";
|
|
4
|
+
import { execFile } from "node:child_process";
|
|
5
|
+
import { join, basename, dirname } from "node:path";
|
|
6
|
+
import { fileURLToPath } from "node:url";
|
|
7
|
+
|
|
8
|
+
let PROFILE = "";
|
|
9
|
+
let PDIR = "";
|
|
10
|
+
let SETTINGS = "";
|
|
11
|
+
let STICKERS = "";
|
|
12
|
+
let TOKEN = "change-me";
|
|
13
|
+
let PORT = 8090;
|
|
14
|
+
const IMG = /\.(png|jpe?g|gif|webp)$/i;
|
|
15
|
+
const FONT_DIR = fileURLToPath(new URL("../assets/fonts", import.meta.url));
|
|
16
|
+
const LABEL = {
|
|
17
|
+
"1-persona.md": "人设",
|
|
18
|
+
"2-rules.md": "语言规则",
|
|
19
|
+
"3-slang.md": "黑话表",
|
|
20
|
+
"4-sticker.md": "表情规则",
|
|
21
|
+
"5-extra.md": "场景规则",
|
|
22
|
+
"6-misc.md": "杂项规则",
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
try { mkdirSync(PDIR, { recursive: true }); } catch {}
|
|
26
|
+
|
|
27
|
+
function run(cmd, args = []) {
|
|
28
|
+
return new Promise((resolve) => {
|
|
29
|
+
execFile(cmd, args, { timeout: 15000 }, (err, stdout, stderr) => {
|
|
30
|
+
resolve({ ok: !err, stdout: String(stdout), stderr: String(stderr) });
|
|
31
|
+
});
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
const noBom = (s) => s.replace(/^\uFEFF/, "");
|
|
35
|
+
|
|
36
|
+
const page = `<!doctype html><html lang="zh"><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
|
37
|
+
<title>dsh 控制台</title>
|
|
38
|
+
<style>
|
|
39
|
+
:root{color-scheme:dark}
|
|
40
|
+
body{font-family:system-ui,-apple-system,"Segoe UI",sans-serif;margin:0;background:#0f1115;color:#dfe4ea;font-size:15px;line-height:1.65}
|
|
41
|
+
header{padding:14px 20px;background:#161a20;border-bottom:1px solid #242a33;display:flex;gap:0;align-items:center;flex-wrap:wrap}
|
|
42
|
+
h1{font-size:18px;margin:0;font-weight:600;letter-spacing:.5px}
|
|
43
|
+
main{padding:20px;max-width:1200px;margin:0 auto}
|
|
44
|
+
.layout{display:grid;grid-template-columns:220px minmax(0,1fr);gap:18px;align-items:start}
|
|
45
|
+
.content{min-width:0}
|
|
46
|
+
nav.tabs{display:flex;flex-direction:column;gap:10px;position:sticky;top:12px}
|
|
47
|
+
.tab{padding:10px 12px;border-radius:8px;background:#171c24;border:1px solid #232a34;cursor:pointer;font-size:13.5px;color:#aeb7c2;user-select:none;margin:0 0 8px 8px}
|
|
48
|
+
.tab.on{background:#2b6cb0;border-color:#2b6cb0;color:#fff;font-weight:600}
|
|
49
|
+
.tab:hover{border-color:#2b6cb0}
|
|
50
|
+
.sdot{width:9px;height:9px;border-radius:50%;display:inline-block;margin-right:6px;vertical-align:middle}
|
|
51
|
+
table{border-collapse:collapse;font-size:14px;width:100%}
|
|
52
|
+
th,td{padding:9px 10px;border-bottom:1px solid #1e242c;text-align:left}
|
|
53
|
+
th{color:#93a0b0;font-weight:600}
|
|
54
|
+
tbody tr:hover{background:rgba(255,255,255,.03)}
|
|
55
|
+
@media(max-width:860px){
|
|
56
|
+
.layout{grid-template-columns:1fr}
|
|
57
|
+
nav.tabs{position:static;flex-direction:row;overflow-x:auto;padding-bottom:6px}
|
|
58
|
+
.tab{white-space:nowrap;padding:9px 12px;font-size:14px}
|
|
59
|
+
main{padding:12px}
|
|
60
|
+
header{padding:12px 14px;gap:10px}
|
|
61
|
+
}
|
|
62
|
+
.panel{display:none;background:#161a20;border:1px solid #242a33;border-radius:10px;padding:14px}
|
|
63
|
+
.panel.on{display:block}
|
|
64
|
+
textarea{width:100%;min-height:460px;background:#0c0f13;color:#dfe4ea;border:1px solid #2a3240;border-radius:8px;padding:12px;font-family:ui-monospace,Consolas,monospace;font-size:14px;line-height:1.7}
|
|
65
|
+
button{background:#2b6cb0;color:#fff;border:0;border-radius:8px;padding:9px 16px;font-size:14px;cursor:pointer}
|
|
66
|
+
button:hover{filter:brightness(1.1)}
|
|
67
|
+
button.ghost{background:#242a33}
|
|
68
|
+
button.danger{background:#a33}
|
|
69
|
+
input[type=number],input[type=text]{background:#0c0f13;color:#d7dbe0;border:1px solid #2a3240;border-radius:8px;padding:8px;width:120px}
|
|
70
|
+
label{display:block;margin:0 0 10px;font-size:14.5px;color:#a8b3c1}
|
|
71
|
+
label input{margin-left:8px}
|
|
72
|
+
.row{display:flex;gap:10px;align-items:center;flex-wrap:wrap;margin-top:12px}
|
|
73
|
+
pre{background:#0c0f13;border:1px solid #2a3240;border-radius:8px;padding:12px;max-height:460px;overflow:auto;font-size:13.5px;line-height:1.65;white-space:pre-wrap}
|
|
74
|
+
.grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(100px,1fr));gap:8px;margin-top:12px}
|
|
75
|
+
.thumb{position:relative;border:1px solid #2a3240;border-radius:8px;overflow:hidden;background:#0c0f13}
|
|
76
|
+
.thumb img{width:100%;height:90px;object-fit:cover;display:block}
|
|
77
|
+
.thumb button{position:absolute;right:4px;top:4px;padding:2px 7px;font-size:11px;background:#a33}
|
|
78
|
+
small{color:#8b95a3;font-size:13.5px}
|
|
79
|
+
.chatcard{display:flex;gap:12px;padding:12px;border:1px solid #242a33;border-radius:12px;background:#12161c;margin-bottom:10px;align-items:center}
|
|
80
|
+
.chatcard.busy{border-color:#2b6cb0}
|
|
81
|
+
.avwrap{flex:0 0 46px}
|
|
82
|
+
.av{width:46px;height:46px;border-radius:50%;object-fit:cover;background:#222}
|
|
83
|
+
.cbody{flex:1;min-width:0}
|
|
84
|
+
.chead{display:flex;align-items:center;gap:8px;font-size:14px;margin-bottom:4px}
|
|
85
|
+
.cname{font-weight:600}
|
|
86
|
+
.msg{font-size:12.5px;color:#9aa4b2;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;margin-top:2px}
|
|
87
|
+
.msg.reply{color:#7fb3e6}
|
|
88
|
+
.cmeta{font-size:11.5px;color:#5f6875;margin-top:4px}
|
|
89
|
+
.gdot{width:8px;height:8px;border-radius:50%;background:#3ddc84;box-shadow:0 0 8px #3ddc84}
|
|
90
|
+
.pill{font-size:11px;padding:2px 8px;border-radius:999px;background:#20262f;color:#8b95a3;margin-left:auto}
|
|
91
|
+
.pill.on{background:#173322;color:#5fdc9a}
|
|
92
|
+
.pill.busy{background:#1b2b40;color:#7fb3e6;animation:pulse 1.2s infinite}
|
|
93
|
+
@keyframes pulse{0%,100%{opacity:1}50%{opacity:.45}}
|
|
94
|
+
#toast{position:fixed;left:50%;transform:translateX(-50%);bottom:24px;background:#2b6cb0;padding:10px 18px;border-radius:8px;opacity:0;transition:.2s}
|
|
95
|
+
#toast.show{opacity:1}
|
|
96
|
+
.chips{display:flex;flex-wrap:wrap;gap:6px}
|
|
97
|
+
.chip{display:inline-flex;align-items:center;gap:6px;background:#1b2029;border:1px solid #2a3140;border-radius:14px;padding:3px 10px;font-size:12.5px}
|
|
98
|
+
.chip b{cursor:pointer;color:#8a93a0;font-weight:400}
|
|
99
|
+
.chip b:hover{color:#e26d6d}
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
</style>
|
|
103
|
+
<style id="pixel-theme">
|
|
104
|
+
@font-face{font-family:"FusionPixel";src:url("/fonts/fusion-pixel-12px.woff2") format("woff2");font-display:swap}
|
|
105
|
+
html,body{background:#0D1117!important;color:#E6EDF3!important;font-family:"FusionPixel",ui-monospace,"Courier New",monospace!important}
|
|
106
|
+
*{border-radius:0!important;box-shadow:none!important}
|
|
107
|
+
header,main,.panel,.chatcard,.thumb,.chip,pre,textarea,input,select,button,.tab,.grphead,#toast{box-shadow:none!important}
|
|
108
|
+
.panel,.chatcard,.thumb,pre,.chip{background:#151B22!important;border:2px solid #2C3742!important;border-top-color:#4E6B7A!important;border-left-color:#4E6B7A!important;border-bottom-color:#0A0D11!important;border-right-color:#0A0D11!important}
|
|
109
|
+
.chatcard{margin-bottom:10px!important}
|
|
110
|
+
button{background:#2E9E92!important;color:#06201D!important;border:2px solid #6FE0D0!important;border-bottom-color:#123B36!important;border-right-color:#123B36!important;padding:6px 12px!important;font-family:inherit!important;cursor:pointer}
|
|
111
|
+
button:hover{filter:brightness(1.12)}
|
|
112
|
+
button:active{border-top-color:#123B36!important;border-left-color:#123B36!important;border-bottom-color:#6FE0D0!important;border-right-color:#6FE0D0!important}
|
|
113
|
+
button.ghost{background:#1B232C!important;border-color:#3A4A57!important;border-bottom-color:#0A0D11!important;border-right-color:#0A0D11!important;color:#DCE6EF!important}
|
|
114
|
+
button.danger{background:#A6433E!important;border-color:#E08A82!important;border-bottom-color:#2A100E!important;border-right-color:#2A100E!important;color:#FFF1EF!important}
|
|
115
|
+
input,select,textarea{background:#0A0E13!important;color:#E6EDF3!important;border:2px solid #2C3742!important;border-top-color:#0A0D11!important;border-left-color:#0A0D11!important;border-bottom-color:#3E5A66!important;border-right-color:#3E5A66!important;padding:6px 8px!important;font-family:inherit!important}
|
|
116
|
+
input:focus,select:focus,textarea:focus{outline:none!important;border-bottom-color:#6FE0D0!important;border-right-color:#6FE0D0!important}
|
|
117
|
+
textarea{line-height:1.9!important}
|
|
118
|
+
a{color:#6FE0D0!important}
|
|
119
|
+
small{color:#9AA7B4!important}
|
|
120
|
+
h1,h2,h3,h4{color:#9BE8DB!important;letter-spacing:1px!important}
|
|
121
|
+
nav.tabs .tab{padding:8px 10px!important;background:#131A21!important;color:#AEBBC8!important;border:2px solid #26313B!important;border-top-color:#3E5A66!important;border-left-color:#3E5A66!important;border-bottom-color:#0A0D11!important;border-right-color:#0A0D11!important;margin:0 0 8px 6px!important;font-size:13.5px!important}
|
|
122
|
+
nav.tabs .tab.on{background:#245E58!important;color:#D9FBF5!important;border-color:#6FE0D0!important;border-bottom-color:#0E2E2A!important;border-right-color:#0E2E2A!important;font-weight:700}
|
|
123
|
+
#fgHead,#wqHead,#cfgHead,#othHead{background:#1B3330!important;color:#D9FBF5!important;border:2px solid #4E6B7A!important;border-bottom-color:#0A0D11!important;border-right-color:#0A0D11!important}
|
|
124
|
+
.pill{background:#1B232C!important;color:#AEBBC8!important;border:1px solid #33414D!important}
|
|
125
|
+
.pill.on{background:#1E3327!important;color:#A8D8B0!important}
|
|
126
|
+
.pill.busy{background:#2A2432!important;color:#CDB8E6!important}
|
|
127
|
+
.gdot{background:#57C99A!important;box-shadow:none!important}
|
|
128
|
+
#toast{background:#245E58!important;border:2px solid #6FE0D0!important;color:#D9FBF5!important}
|
|
129
|
+
input[type=range]{-webkit-appearance:none;appearance:none;height:24px!important;background:transparent!important;border:0!important;padding:0!important;cursor:pointer}
|
|
130
|
+
input[type=range]::-webkit-slider-runnable-track{height:16px;background:#0A0E13;border:2px solid #2C3742;border-top-color:#0A0D11;border-left-color:#0A0D11;border-bottom-color:#3E5A66;border-right-color:#3E5A66}
|
|
131
|
+
input[type=range]::-webkit-slider-thumb{-webkit-appearance:none;width:20px;height:26px;margin-top:-7px;background:#2E9E92;border:2px solid #6FE0D0;border-bottom-color:#123B36;border-right-color:#123B36}
|
|
132
|
+
input[type=range]::-moz-range-track{height:14px;background:#0A0E13;border:2px solid #2C3742}
|
|
133
|
+
input[type=range]::-moz-range-thumb{width:18px;height:24px;border-radius:0;background:#2E9E92;border:2px solid #6FE0D0}
|
|
134
|
+
div:has(> input[type=range])::before{content:"◀";color:#4E6B7A;font-size:12px;margin-right:6px}
|
|
135
|
+
div:has(> input[type=range])::after{content:"▶";color:#4E6B7A;font-size:12px;margin-left:6px}
|
|
136
|
+
label:has(input[type=range])::after{content:"(拖动滑块调整)";color:#6B7B8A;font-size:11.5px;margin-left:6px}
|
|
137
|
+
/* ===== 鲸鱼海洋主题:淡蓝 / 低饱和 / 低对比 / 圆角 ===== */
|
|
138
|
+
html,body{background:#0B1118!important;background-image:none!important;color:#AEBEC9!important;min-height:100%!important}
|
|
139
|
+
body{background-attachment:fixed!important}
|
|
140
|
+
header{background:#0F1820!important;border-bottom:1px solid #1E2A36!important}
|
|
141
|
+
h1{color:#C2D0DB!important}
|
|
142
|
+
.panel,.chatcard,.thumb,pre,.chip{background:#111A23!important;border:1px solid #1E2A36!important;color:#AEBEC9!important;border-radius:10px!important}
|
|
143
|
+
nav.tabs .tab,.tab{background:#131D26!important;border:1px solid #1E2A36!important;color:#7D8C99!important;border-radius:8px!important}
|
|
144
|
+
nav.tabs .tab.on,.tab.on{background:#22323E!important;border-color:#48606F!important;color:#C2D0DB!important;font-weight:600}
|
|
145
|
+
#fgHead,#wqHead,#cfgHead,#othHead{background:#0F1821!important;color:#B4C4D0!important;border:1px solid #222F3B!important;border-top-color:#3C5261!important;border-radius:9px!important}
|
|
146
|
+
button{background:#18242E!important;border:1px solid #22303B!important;color:#AEBEC9!important;border-radius:8px!important}
|
|
147
|
+
button:hover{background:#1E2D39!important;color:#C4D2DD!important}
|
|
148
|
+
button.ghost{background:transparent!important;border-color:#22303B!important;color:#8A9AA7!important;border-radius:8px!important}
|
|
149
|
+
button.danger{background:#2E2429!important;border-color:#544850!important;color:#C6BCC2!important;border-radius:8px!important}
|
|
150
|
+
input,textarea,select{background:#0D161F!important;border:1px solid #1F2C37!important;color:#AEBEC9!important;border-radius:8px!important}
|
|
151
|
+
input::placeholder,textarea::placeholder{color:#4E5B66!important}
|
|
152
|
+
a{color:#74919D!important}
|
|
153
|
+
small{color:#647280!important}
|
|
154
|
+
.chips .chip,.chip{background:#14202A!important;color:#A5B6C2!important;border-color:#202B31!important;border-radius:999px!important}
|
|
155
|
+
.pill{background:#14202A!important;color:#8098A4!important;border-radius:999px!important}
|
|
156
|
+
tr:hover{background:rgba(120,160,190,.05)!important}
|
|
157
|
+
::-webkit-scrollbar{background:#0B1118}
|
|
158
|
+
::-webkit-scrollbar-thumb{background:#1E2A36;border-radius:8px}
|
|
159
|
+
img.thumb,.thumb img{border-radius:8px!important}
|
|
160
|
+
</style>
|
|
161
|
+
<header><h1>dsh 控制台</h1><span id="status"><small>加载中…</small></span>
|
|
162
|
+
<span style="flex:1"></span><button class="ghost" onclick="load()">刷新</button><button onclick="restart()">重启 DSH</button></header>
|
|
163
|
+
<main>
|
|
164
|
+
<div class="layout">
|
|
165
|
+
<nav class="tabs" id="tabs"></nav>
|
|
166
|
+
<div class="content">
|
|
167
|
+
<div id="panels"></div>
|
|
168
|
+
|
|
169
|
+
<div class="panel" id="p-slang"><div class="row" style="justify-content:space-between"><div style="display:flex;gap:8px;align-items:center"><button class="ghost" onclick="loadSlang()">刷新</button><small>为了让 bot 听懂骂人的话用的,被骂时也会回怼</small></div><span style="display:flex;gap:8px;align-items:center"><span id="slangPager" style="display:flex;gap:6px;align-items:center;font-size:12px;color:#8a93a0"></span></span></div>
|
|
170
|
+
<div class="row" style="gap:6px;margin-bottom:8px"><input id="slangTerm" placeholder="词" style="width:120px"><input id="slangMeaning" placeholder="意思" style="flex:1"><button onclick="addSlang()">添加</button></div>
|
|
171
|
+
<div id="slangList"></div></div>
|
|
172
|
+
<div class="panel" id="p-banned"><div class="row" style="justify-content:space-between"><div style="display:flex;gap:8px;align-items:center"><button class="ghost" onclick="loadBanned()">刷新</button><small>生成回复时直接从文本里删掉这些词</small></div><span style="display:flex;gap:8px;align-items:center"><span id="bannedPager" style="display:flex;gap:6px;align-items:center;font-size:12px;color:#8a93a0"></span></span></div>
|
|
173
|
+
<div class="row" style="gap:6px;margin-bottom:8px"><input id="bannedWord" placeholder="词" style="flex:1"><button onclick="addBanned()">添加</button></div>
|
|
174
|
+
<div id="bannedList"></div></div>
|
|
175
|
+
<div class="panel" id="p-settings">
|
|
176
|
+
<div class="chatcard" style="display:block">
|
|
177
|
+
<div style="display:flex;align-items:center;gap:10px;margin-bottom:8px"><b>QQ 登录</b><button class="ghost" onclick="loadQqQr()">刷新二维码</button><span id="qqQrTip" style="color:#8a93a0;font-size:12px"></span></div>
|
|
178
|
+
<div id="qqSelfBox" style="display:none;align-items:center;gap:10px;margin-bottom:8px">
|
|
179
|
+
<img id="qqSelfAvatar" alt="" style="width:48px;height:48px;border-radius:50%;object-fit:cover;background:#0b0f14">
|
|
180
|
+
<div><div id="qqSelfId" style="font-size:13px;color:#dbe3ee"></div><div style="color:#7d8794;font-size:11px">已登录</div></div>
|
|
181
|
+
</div>
|
|
182
|
+
<img id="qqQrImg" alt="QQ 登录二维码" style="width:180px;height:180px;background:#fff;border-radius:8px;padding:6px;object-fit:contain">
|
|
183
|
+
<div style="color:#7d8794;font-size:11px;margin-top:6px">用手机 QQ 扫码登录 NapCat;过期点刷新。登录成功后机器人会自动上线。</div>
|
|
184
|
+
</div>
|
|
185
|
+
<div id="settingsFields"></div>
|
|
186
|
+
<div id="winHelp" style="display:none;position:fixed;z-index:99;width:min(780px,94vw);max-height:76vh;overflow:auto;background:#1b222b;border:1px solid rgba(255,255,255,.12);border-radius:12px;padding:20px 24px;box-shadow:0 12px 40px rgba(0,0,0,.5)">
|
|
187
|
+
<div style="font-size:17px;font-weight:600;margin-bottom:10px;color:#dbe3ee">接话窗口使用说明</div>
|
|
188
|
+
<div style="font-size:13.5px;line-height:1.9;color:#c8d0da">
|
|
189
|
+
就是它「不 @ 它,也会接着跟你聊」的那段时间。有人 @ 它、叫它名字,或者它刚回过你之后,窗口就打开了;你每再发一句,窗口从头重新计时。窗口还在,它就当你们还在一起聊;窗口结束就把对话关掉,不再接话。
|
|
190
|
+
</div>
|
|
191
|
+
<div style="font-size:14.5px;font-weight:600;margin:14px 0 8px;color:#dbe3ee">进入 / 离开条件</div>
|
|
192
|
+
<div style="font-size:13.5px;line-height:1.9;color:#c8d0da">
|
|
193
|
+
<b>进入</b>:它回你一条;或你 @ 它、叫它名字。<br>
|
|
194
|
+
<b>续期</b>:窗口内它每回你一次,重新计时;你一直说就一直续。<br>
|
|
195
|
+
<b>离开</b>:窗口内你不再说话,窗口自然结束;主动找话在窗口中间判定,说完也不会续期。
|
|
196
|
+
</div>
|
|
197
|
+
<div style="font-size:14.5px;font-weight:600;margin:14px 0 8px;color:#dbe3ee">怎么调(新手)</div>
|
|
198
|
+
<div style="font-size:13.5px;line-height:1.9;color:#c8d0da">
|
|
199
|
+
想黏人调大(3~5 分钟);想安静调小,但最少 1 分钟;太短它以为你走了,太长它爱插嘴。
|
|
200
|
+
</div>
|
|
201
|
+
<svg viewBox="0 0 720 230" style="width:100%;height:auto;margin-top:12px" xmlns="http://www.w3.org/2000/svg">
|
|
202
|
+
<defs><marker id="ah2" markerWidth="8" markerHeight="8" refX="6" refY="3" orient="auto"><path d="M0,0 L6,3 L0,6 z" fill="#8a93a0"/></marker></defs>
|
|
203
|
+
<line x1="30" y1="150" x2="690" y2="150" stroke="#8a93a0" stroke-width="1.5" marker-end="url(#ah2)"/>
|
|
204
|
+
<text x="30" y="176" fill="#7d8794" font-size="11">时间</text>
|
|
205
|
+
<line x1="90" y1="150" x2="90" y2="60" stroke="#6ea8fe" stroke-width="1.5"/>
|
|
206
|
+
<circle cx="90" cy="150" r="5" fill="#6ea8fe"/>
|
|
207
|
+
<text x="40" y="46" fill="#a9c4f5" font-size="12">进入:回你 / @机器人 / 叫机器人名字</text>
|
|
208
|
+
<rect x="100" y="130" width="320" height="14" rx="7" fill="rgba(110,168,254,.35)" stroke="rgba(110,168,254,.7)"/>
|
|
209
|
+
<text x="104" y="170" fill="#a9c4f5" font-size="12">窗口打开(默认 120 秒,最少 60 秒)</text>
|
|
210
|
+
<line x1="190" y1="150" x2="190" y2="106" stroke="#6ea8fe" stroke-width="1.2" stroke-dasharray="3,3"/>
|
|
211
|
+
<text x="150" y="98" fill="#8a93a0" font-size="11">又说话 → 重新计时</text>
|
|
212
|
+
<line x1="320" y1="150" x2="320" y2="106" stroke="#6ea8fe" stroke-width="1.2" stroke-dasharray="3,3"/>
|
|
213
|
+
<text x="300" y="98" fill="#8a93a0" font-size="11">再说 → 再续</text>
|
|
214
|
+
<line x1="260" y1="150" x2="260" y2="112" stroke="#f0b04f" stroke-width="1.5"/>
|
|
215
|
+
<circle cx="260" cy="150" r="5" fill="#f0b04f"/>
|
|
216
|
+
<text x="176" y="70" fill="#f0b04f" font-size="12">窗口中间:判定一次主动找话</text>
|
|
217
|
+
<line x1="430" y1="150" x2="430" y2="112" stroke="#8a93a0" stroke-width="1.5"/>
|
|
218
|
+
<circle cx="430" cy="150" r="5" fill="#8a93a0"/>
|
|
219
|
+
<rect x="430" y="158" width="240" height="10" rx="5" fill="rgba(240,176,79,.25)" stroke="rgba(240,176,79,.6)"/>
|
|
220
|
+
<text x="436" y="192" fill="#8a93a0" font-size="11">窗口结束:关闭对话,不再接话</text>
|
|
221
|
+
<text x="436" y="210" fill="#8a93a0" font-size="11">(主动说了一句也不续期)</text>
|
|
222
|
+
</svg>
|
|
223
|
+
</div>
|
|
224
|
+
<div id="permBlock" style="background:rgba(255,255,255,.035);border:1px solid rgba(255,255,255,.07);border-radius:12px;padding:16px 16px 10px;margin-top:6px">
|
|
225
|
+
<div style="display:flex;justify-content:flex-start;margin:0 0 16px"><span id="roleHelpBtn" style="display:inline-flex;align-items:center;gap:8px;cursor:help;font-size:14px;color:#cfd8e3;user-select:none"><span style="display:inline-flex;width:20px;height:20px;border-radius:50%;border:1.5px solid #cfd8e3;align-items:center;justify-content:center;font-size:13px">!</span>权限说明</span></div>
|
|
226
|
+
<div style="font-size:12.5px;color:#8a93a0;margin-bottom:4px">管理员名单</div>
|
|
227
|
+
<div id="ownersChips" class="chips"></div>
|
|
228
|
+
<div class="row" style="gap:6px;margin:6px 0 12px">
|
|
229
|
+
<input id="ownerAdd" placeholder="加管理员 QQ" style="width:150px">
|
|
230
|
+
<button class="ghost" onclick="addChip('owner')">添加</button>
|
|
231
|
+
</div>
|
|
232
|
+
<div style="font-size:12.5px;color:#8a93a0;margin:12px 0 4px">DSH 后台权限名单(最高权限,可用 /dsh 与 shell)</div>
|
|
233
|
+
<div id="adminsChips" class="chips"></div>
|
|
234
|
+
<div class="row" style="gap:6px;margin-top:6px">
|
|
235
|
+
<input id="adminAdd" placeholder="加 DSH 后台权限 QQ" style="width:150px">
|
|
236
|
+
<button class="ghost" onclick="addChip('admin')">添加</button>
|
|
237
|
+
</div>
|
|
238
|
+
</div>
|
|
239
|
+
<div class="row" style="display:none"><button onclick="saveAllSettings()">保存</button></div></div>
|
|
240
|
+
<div id="roleHelp" style="display:none;position:fixed;z-index:99;width:min(720px,94vw);max-height:76vh;overflow:auto;background:#1b222b;border:1px solid rgba(255,255,255,.12);border-radius:12px;padding:20px 24px;box-shadow:0 12px 40px rgba(0,0,0,.5)">
|
|
241
|
+
<div style="font-size:17px;font-weight:600;margin-bottom:10px;color:#dbe3ee">权限说明</div>
|
|
242
|
+
<div style="font-size:14.5px;font-weight:600;margin:14px 0 8px;color:#9fc4ff">管理员能做什么</div>
|
|
243
|
+
<div style="font-size:13.5px;line-height:1.9;color:#c8d0da">
|
|
244
|
+
· <b>跨群 / 私聊说话、@ 人</b>:例「去测试群说一声今晚八点开会」;也可以在私聊里遥控,例「去测试群 @ 一下老王,说我晚点到」<br>
|
|
245
|
+
· <b>处理申请</b>:例「看看有没有好友申请,先别同意」「有人申请进群的话先问我」<br>
|
|
246
|
+
· <b>退群</b>:例「把那个广告群退了」<br>
|
|
247
|
+
· <b>管群</b>:例「把@某人 禁言十分钟」「把这条广告撤回」「给他改成群名片老王」<br>
|
|
248
|
+
· <b>删表情</b>:例「把表情库里那张糊了的删掉」<br>
|
|
249
|
+
· <b>教它认人</b>:例「以后管 QQ 123456789 叫老王」「把 123456789 的档案记成:楼下卖水果的,爱发猫图」,之后它就会按这个叫、按这个聊<br>
|
|
250
|
+
· <b>加禁词</b>:例「以后别说『笑死』」<br>
|
|
251
|
+
· <b>组合任务</b>:例「帮我去网上找一下最近的 XX 新闻,然后写一份群公告发到测试群」<br>
|
|
252
|
+
· <b>命令</b>:<b>/mood</b> 看好感度,<b>/setmood 0-100</b> 设定,例「/setmood 80」
|
|
253
|
+
</div>
|
|
254
|
+
<div style="font-size:14.5px;font-weight:600;margin:16px 0 8px;color:#f0b04f">DSH 后台权限怎么用</div>
|
|
255
|
+
<div style="font-size:13.5px;line-height:1.9;color:#c8d0da">
|
|
256
|
+
这个权限就是让名单里的人通过机器人账号去访问 DSH。<br>
|
|
257
|
+
· <b>/dsh 对话</b>:例「/dsh 看下服务器还剩多少磁盘」<br>
|
|
258
|
+
· <b>服务器命令</b>:例「用 shell 看看哪个进程占内存最多」「用 shell 重启一下 dsh-qqbot」<br>
|
|
259
|
+
· <b>改配置 / 装插件</b>:例「把接话窗口改成 180 秒」「帮我装个新插件」<br>
|
|
260
|
+
· <b>加名单</b>:例「把 QQ 123456789 加进 DSH 后台权限」,点底部「保存」生效<br>
|
|
261
|
+
<span style="color:#8a93a0">以上只是示范,不是只靠某个特殊词语触发,请自行探索。</span>
|
|
262
|
+
</div>
|
|
263
|
+
</div>
|
|
264
|
+
|
|
265
|
+
<div class="panel" id="p-memes">
|
|
266
|
+
<div class="row" style="justify-content:space-between;align-items:center">
|
|
267
|
+
<span id="memeHelpBtn" style="display:inline-flex;align-items:center;gap:8px;cursor:help;font-size:14px;color:#cfd8e3;user-select:none"><span style="display:inline-flex;width:20px;height:20px;border-radius:50%;border:1.5px solid #cfd8e3;align-items:center;justify-content:center;font-size:13px">!</span>梗百科/知识库说明</span>
|
|
268
|
+
<span style="display:flex;gap:10px;align-items:center;font-size:12px;color:#8a93a0"><span id="memeCount"></span><span id="memePager" style="display:flex;gap:6px;align-items:center"></span></span>
|
|
269
|
+
</div>
|
|
270
|
+
|
|
271
|
+
<div id="memeHelp" style="display:none;position:fixed;z-index:99;width:min(520px,94vw);background:#1b222b;border:1px solid rgba(255,255,255,.14);border-radius:8px;padding:16px 18px;box-shadow:0 12px 40px rgba(0,0,0,.5);font-size:13px;line-height:1.8;color:#c8d0da">
|
|
272
|
+
<div style="font-size:15px;font-weight:600;margin-bottom:8px;color:#dbe3ee">梗百科/知识库说明</div>
|
|
273
|
+
插件内置的梗百科/知识库不设初始内容。使用者可以根据自己或群聊的需要,让 DSH 去找内容往里填,避免一次塞进太多垃圾信息。<br><br>
|
|
274
|
+
· 「内容」不要求固定格式:梗是什么、出处、什么场合用,写清楚就行。<br>
|
|
275
|
+
· 「标签」可选,方便搜索,比如「游戏」「主播」。<br>
|
|
276
|
+
· 每页 8 条,搜索按标题、内容、标签匹配。
|
|
277
|
+
</div>
|
|
278
|
+
<div class="row" style="gap:6px">
|
|
279
|
+
<input id="memeSearch" placeholder="搜索标题 / 内容 / 标签" style="flex:1;min-width:0" onkeydown="if(event.key==='Enter') loadMemes(0)">
|
|
280
|
+
<button onclick="loadMemes(0)">搜索</button>
|
|
281
|
+
</div>
|
|
282
|
+
<div id="memeList" style="margin-top:8px"></div>
|
|
283
|
+
<div class="row" style="gap:6px;margin-top:10px">
|
|
284
|
+
<input id="memeNewTitle" placeholder="标题" style="width:180px">
|
|
285
|
+
<input id="memeNewTag" placeholder="标签(可选)" style="width:140px">
|
|
286
|
+
<button onclick="addMeme()">新增</button>
|
|
287
|
+
</div>
|
|
288
|
+
</div>
|
|
289
|
+
<div class="panel" id="p-stickers"><div class="row"><button id="pickSticker">上传表情</button><input type="file" id="file" accept="image/*" multiple style="display:none"><small id="stickerCount"></small></div><div class="grid" id="grid"></div></div>
|
|
290
|
+
|
|
291
|
+
<div class="panel" id="p-chats">
|
|
292
|
+
<div class="row" style="justify-content:space-between;align-items:center">
|
|
293
|
+
<span id="chatHelpBtn" style="display:inline-flex;align-items:center;gap:8px;cursor:help;font-size:14px;color:#cfd8e3;user-select:none"><span style="display:inline-flex;width:20px;height:20px;border-radius:50%;border:1.5px solid #cfd8e3;align-items:center;justify-content:center;font-size:13px">!</span>摘要说明</span>
|
|
294
|
+
<button class="ghost" onclick="loadChats()">刷新</button>
|
|
295
|
+
</div>
|
|
296
|
+
<div style="color:#7d8794;font-size:11.5px;line-height:1.7;margin:0 0 8px">按最后活跃排序,每页 3 个</div>
|
|
297
|
+
<div id="chatHelp" style="display:none;position:fixed;z-index:99;width:min(560px,94vw);background:#1b222b;border:1px solid rgba(255,255,255,.14);border-radius:8px;padding:16px 18px;box-shadow:0 12px 40px rgba(0,0,0,.5);font-size:13px;line-height:1.8;color:#c8d0da">
|
|
298
|
+
<div style="font-size:15px;font-weight:600;margin-bottom:8px;color:#dbe3ee">会话摘要规则</div>
|
|
299
|
+
· 一段会话聊满 <b style="color:#f0b04f">30 次</b>(一次 = 对方发一条消息它处理一遍,不是 30 条消息)就自动换新会话,旧的压成一段摘要。<br>
|
|
300
|
+
· 累计字符超过 <b style="color:#f0b04f">40 万</b>也会触发;没有空闲重置,空闲不会换会话。<br>
|
|
301
|
+
· 上面两个数都能在「行为参数」里调(会话次数上限、会话字符上限)。<br>
|
|
302
|
+
· 摘要按时间倒序存,点会话上的「摘要」会弹窗显示,最新的在左上。<br>
|
|
303
|
+
· 每个会话最多存 <b style="color:#8a93a0">9 段</b>(这个写死在代码里,参数里调不了),满了删最旧的。<br>
|
|
304
|
+
· 摘要也会带进新会话,所以换会话不会全忘。
|
|
305
|
+
</div>
|
|
306
|
+
<div id="sumOverlay" style="display:none;position:fixed;inset:0;z-index:98;background:rgba(0,0,0,.62);align-items:center;justify-content:center">
|
|
307
|
+
<div style="width:min(760px,94vw);max-height:86vh;overflow:auto;background:#181b1f;border:2px solid #b9c0c8;padding:16px 18px">
|
|
308
|
+
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:10px">
|
|
309
|
+
<b id="sumTitle" style="color:#dbe3ee">会话摘要</b>
|
|
310
|
+
<button class="danger" id="sumClose" style="padding:2px 9px">×</button>
|
|
311
|
+
</div>
|
|
312
|
+
<div id="sumGrid" style="display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:8px"></div>
|
|
313
|
+
</div>
|
|
314
|
+
</div>
|
|
315
|
+
<div style="background:rgba(255,255,255,.035);border:1px solid rgba(255,255,255,.07);border-radius:12px;padding:14px 16px;margin-bottom:16px">
|
|
316
|
+
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:10px">
|
|
317
|
+
<span style="font-size:14.5px;font-weight:600;color:#dbe3ee">群聊 <span id="chatGCount" style="color:#8a93a0;font-size:12px;font-weight:400"></span></span>
|
|
318
|
+
<span id="chatGPager" style="display:flex;gap:6px;align-items:center;font-size:12px;color:#8a93a0"></span>
|
|
319
|
+
</div>
|
|
320
|
+
<div id="chatGroups" style="height:432px;overflow:hidden"></div>
|
|
321
|
+
</div>
|
|
322
|
+
<div style="background:rgba(255,255,255,.035);border:1px solid rgba(255,255,255,.07);border-radius:12px;padding:14px 16px">
|
|
323
|
+
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:10px">
|
|
324
|
+
<span style="font-size:14.5px;font-weight:600;color:#dbe3ee">私聊 <span id="chatPCount" style="color:#8a93a0;font-size:12px;font-weight:400"></span></span>
|
|
325
|
+
<span id="chatPPager" style="display:flex;gap:6px;align-items:center;font-size:12px;color:#8a93a0"></span>
|
|
326
|
+
</div>
|
|
327
|
+
<div id="chatPrivs" style="height:432px;overflow:hidden"></div>
|
|
328
|
+
</div>
|
|
329
|
+
</div>
|
|
330
|
+
<div class="panel" id="p-tics"><div class="row" style="justify-content:space-between"><div style="display:flex;gap:8px;align-items:center"><button class="ghost" onclick="loadTics()">刷新</button><small>具体模仿规则</small></div><span style="display:flex;gap:8px;align-items:center"><span id="ticPager" style="display:flex;gap:6px;align-items:center;font-size:12px;color:#8a93a0"></span></span></div>
|
|
331
|
+
<div style="color:#7d8794;font-size:11.5px;line-height:1.75;margin:0 0 8px">自动收录:2–6 字的话 3 天内出现 3 次 → 口头禅;和上下文对不上的长句 → 提炼成句式。相似就覆盖旧的,最多 80 条,满了删最旧的。使用频率:每 30 次对话最多用 5 次。</div>
|
|
332
|
+
<div class="row" style="gap:6px;margin-bottom:8px"><input id="ticScenario" placeholder="场景:这条用在哪" style="flex:1"><input id="ticPhrase" placeholder="说法" style="width:180px"><select id="ticKind" style="width:100px" onchange="var e=document.getElementById('ticScenario'); if(e) e.style.display=this.value==='pattern'?'none':''"><option value="phrase">口头禅</option><option value="pattern">句式</option></select><button onclick="addTic()">添加</button></div>
|
|
333
|
+
<div id="ticList"></div></div>
|
|
334
|
+
<div class="panel" id="p-reminders">
|
|
335
|
+
<div class="row" style="justify-content:space-between;align-items:center">
|
|
336
|
+
<span id="remindHelpBtn" style="display:inline-flex;align-items:center;gap:8px;cursor:help;font-size:14px;color:#cfd8e3;user-select:none"><span style="display:inline-flex;width:20px;height:20px;border-radius:50%;border:1.5px solid #cfd8e3;align-items:center;justify-content:center;font-size:13px">!</span>待办说明</span>
|
|
337
|
+
<button class="ghost" onclick="loadReminders()">刷新</button>
|
|
338
|
+
</div>
|
|
339
|
+
<div style="color:#7d8794;font-size:11.5px;line-height:1.7;margin:0 0 8px">全局共 10 条上限,到点 @ 发起人</div>
|
|
340
|
+
<div id="remindHelp" style="display:none;position:fixed;z-index:99;width:min(520px,94vw);background:#1b222b;border:1px solid rgba(255,255,255,.14);border-radius:8px;padding:16px 18px;box-shadow:0 12px 40px rgba(0,0,0,.5);font-size:13px;line-height:1.8;color:#c8d0da">
|
|
341
|
+
<div style="font-size:15px;font-weight:600;margin-bottom:8px;color:#dbe3ee">待办规则</div>
|
|
342
|
+
· 最多 <b>10 条</b>,全部聊天合计;满了要先取消几条。<br>
|
|
343
|
+
· 每条独立计时,到点各自提醒,互不影响、不分先后。<br>
|
|
344
|
+
· 可以说提醒谁(昵称或 QQ),不说就提醒自己。<br>
|
|
345
|
+
· 「某人出现就 @ 我」:按群监听,默认只提醒一次,也可以要求每次都提醒。<br>
|
|
346
|
+
· 不支持连续重复 @:QQ 会拦截同一人短时间内的重复 @,只能提醒一次。<br>
|
|
347
|
+
· 取消按关键词匹配,命中就删。
|
|
348
|
+
<div style="margin-top:10px;color:#9aa4b2">用法示例</div>
|
|
349
|
+
· 「明早七点喊我起床」<br>
|
|
350
|
+
· 「一小时后喊小明喝水」→ 到点 @ 小明<br>
|
|
351
|
+
· 「小明出现就 @ 我」→ 只提醒一次<br>
|
|
352
|
+
· 「每次小明出现都 @ 我」→ 一直提醒,直到取消<br>
|
|
353
|
+
· 「把喝水那个提醒取消了」
|
|
354
|
+
</div>
|
|
355
|
+
<div id="remindList"></div></div>
|
|
356
|
+
<div class="panel" id="p-contacts">
|
|
357
|
+
<div class="row" style="justify-content:space-between"><div style="display:flex;gap:8px;align-items:center"><button class="ghost" onclick="loadContacts()">刷新</button><small>QQ 号 → 称呼,聊天时会照这个叫</small></div><span id="contactPager" style="display:flex;gap:6px;align-items:center;font-size:12px;color:#8a93a0"></span></div>
|
|
358
|
+
<div id="contactList"></div>
|
|
359
|
+
<div class="row" style="gap:8px;margin-top:8px">
|
|
360
|
+
<input id="cQQ" placeholder="QQ 号" style="width:140px">
|
|
361
|
+
<input id="cName" placeholder="名字" style="width:140px"><input id="cNick" placeholder="称呼">
|
|
362
|
+
<input id="cNote" placeholder="简介(可选)" style="width:min(320px,100%)">
|
|
363
|
+
<button onclick="saveContact()">添加/修改</button>
|
|
364
|
+
</div>
|
|
365
|
+
</div>
|
|
366
|
+
|
|
367
|
+
<div class="panel" id="p-logs"><div class="row"><button class="ghost" onclick="loadLogs()">刷新日志</button></div><pre id="logs"></pre></div>
|
|
368
|
+
</div>
|
|
369
|
+
</div>
|
|
370
|
+
</main>
|
|
371
|
+
<div id="toast"></div>
|
|
372
|
+
<script>
|
|
373
|
+
const T = localStorage.getItem('token') || prompt('访问令牌') || '';
|
|
374
|
+
localStorage.setItem('token', T);
|
|
375
|
+
let FILES = [];
|
|
376
|
+
let STICKERS = [];
|
|
377
|
+
function renderGrid(){
|
|
378
|
+
document.getElementById('stickerCount').textContent = '('+STICKERS.length+' 张)';
|
|
379
|
+
document.getElementById('grid').innerHTML = STICKERS.map(n=>'<div class="thumb"><img loading="lazy" data-thumb="/api/sticker?thumb=1&name='+encodeURIComponent(n)+'&t='+encodeURIComponent(T)+'" data-anim="/api/sticker?anim=1&name='+encodeURIComponent(n)+'&t='+encodeURIComponent(T)+'" src="/api/sticker?thumb=1&name='+encodeURIComponent(n)+'&t='+encodeURIComponent(T)+'"><button onclick="del(\\''+n.replace(/'/g,"")+'\\')">×</button></div>').join('');
|
|
380
|
+
watchStickerAnims();
|
|
381
|
+
}
|
|
382
|
+
function watchStickerAnims(){
|
|
383
|
+
try {
|
|
384
|
+
const imgs = document.querySelectorAll('#grid img[data-anim]');
|
|
385
|
+
if (!('IntersectionObserver' in window)) {
|
|
386
|
+
imgs.forEach(function(im){ im.src = im.dataset.anim; });
|
|
387
|
+
return;
|
|
388
|
+
}
|
|
389
|
+
if (!window.__stickObs) {
|
|
390
|
+
window.__stickObs = new IntersectionObserver(function(entries){
|
|
391
|
+
entries.forEach(function(en){
|
|
392
|
+
if (!en.isIntersecting) return;
|
|
393
|
+
const im = en.target;
|
|
394
|
+
if (im.dataset.anim && im.src !== im.dataset.anim) im.src = im.dataset.anim;
|
|
395
|
+
window.__stickObs.unobserve(im);
|
|
396
|
+
});
|
|
397
|
+
}, { rootMargin: '200px' });
|
|
398
|
+
}
|
|
399
|
+
imgs.forEach(function(im){ window.__stickObs.observe(im); });
|
|
400
|
+
} catch (e) {}
|
|
401
|
+
}
|
|
402
|
+
async function refreshStickers(){
|
|
403
|
+
const s = await api('/api/state');
|
|
404
|
+
STICKERS = s.stickers;
|
|
405
|
+
renderGrid();
|
|
406
|
+
}
|
|
407
|
+
async function api(path, opts={}) {
|
|
408
|
+
opts.headers = Object.assign({'x-admin-token': T, 'content-type':'application/json'}, opts.headers||{});
|
|
409
|
+
const r = await fetch(path, opts);
|
|
410
|
+
if (!r.ok) throw new Error(await r.text());
|
|
411
|
+
return r.headers.get('content-type')?.includes('json') ? r.json() : r.text();
|
|
412
|
+
}
|
|
413
|
+
function askConfirm(message, onOk){
|
|
414
|
+
const ov = document.createElement('div');
|
|
415
|
+
ov.style.cssText = 'position:fixed;inset:0;z-index:999;background:rgba(0,0,0,.62);display:flex;align-items:center;justify-content:center';
|
|
416
|
+
const box = document.createElement('div');
|
|
417
|
+
box.style.cssText = 'background:#181b1f;border:2px solid #b9c0c8;padding:16px 18px;min-width:260px;max-width:92vw;color:#e6e9ed;font-size:13px;line-height:1.7';
|
|
418
|
+
const msg = document.createElement('div');
|
|
419
|
+
msg.textContent = message;
|
|
420
|
+
const row = document.createElement('div');
|
|
421
|
+
row.style.cssText = 'display:flex;gap:8px;justify-content:flex-end;margin-top:14px';
|
|
422
|
+
const no = document.createElement('button');
|
|
423
|
+
no.className = 'ghost';
|
|
424
|
+
no.textContent = '取消';
|
|
425
|
+
const yes = document.createElement('button');
|
|
426
|
+
yes.textContent = '确定';
|
|
427
|
+
row.appendChild(no); row.appendChild(yes);
|
|
428
|
+
box.appendChild(msg); box.appendChild(row); ov.appendChild(box);
|
|
429
|
+
document.body.appendChild(ov);
|
|
430
|
+
no.onclick = function(){ ov.remove(); };
|
|
431
|
+
yes.onclick = async function(){ ov.remove(); try { await onOk(); } catch (e) { toast('操作失败'); } };
|
|
432
|
+
}
|
|
433
|
+
function toast(m){const t=document.getElementById('toast');t.textContent=m;t.classList.add('show');setTimeout(()=>t.classList.remove('show'),1500)}
|
|
434
|
+
function tab(id){
|
|
435
|
+
document.querySelectorAll('.tab').forEach(e=>e.classList.toggle('on', e.dataset.id===id));
|
|
436
|
+
document.querySelectorAll('.panel').forEach(e=>e.classList.toggle('on', e.id==='p-'+id));
|
|
437
|
+
if(id==='logs') loadLogs();
|
|
438
|
+
if(id==='chats') loadChats();
|
|
439
|
+
if(id==='tics') loadTics();
|
|
440
|
+
if(id==='reminders') loadReminders();
|
|
441
|
+
if(id==='contacts') loadContacts();
|
|
442
|
+
if(id==='settings') { loadAdmins(); loadQqQr(); }
|
|
443
|
+
if(id==='slang') loadSlang();
|
|
444
|
+
if(id==='banned') loadBanned();
|
|
445
|
+
if(id==='memes') loadMemes();
|
|
446
|
+
if(id==='config') { loadConfig(); loadBiliMe(); loadBiliMem(); }
|
|
447
|
+
if(id==='proxy') loadProxy();
|
|
448
|
+
if(id==='tieba') loadTieba();
|
|
449
|
+
}
|
|
450
|
+
async function loadConfig(){
|
|
451
|
+
try {
|
|
452
|
+
const st = await api('/api/state');
|
|
453
|
+
const s = (st && st.settings) || {};
|
|
454
|
+
document.getElementById('cfg-arkKey').value = s.arkKey || '';
|
|
455
|
+
} catch (e) { toast('读取失败'); }
|
|
456
|
+
}
|
|
457
|
+
async function loadProxy(){
|
|
458
|
+
try {
|
|
459
|
+
const st = await api('/api/state');
|
|
460
|
+
const s = (st && st.settings) || {};
|
|
461
|
+
document.getElementById('px-proxyUrl').value = s.proxyUrl || '';
|
|
462
|
+
document.getElementById('px-clashApi').value = s.clashApi || '';
|
|
463
|
+
document.getElementById('px-clashGroup').value = s.clashGroup || '';
|
|
464
|
+
} catch (e) { toast('读取失败'); }
|
|
465
|
+
}
|
|
466
|
+
async function saveProxy(silent){
|
|
467
|
+
const body = {
|
|
468
|
+
proxyUrl: document.getElementById('px-proxyUrl').value.trim(),
|
|
469
|
+
clashApi: document.getElementById('px-clashApi').value.trim(),
|
|
470
|
+
clashGroup: document.getElementById('px-clashGroup').value.trim()
|
|
471
|
+
};
|
|
472
|
+
await api('/api/settings', { method:'POST', body: JSON.stringify(body) });
|
|
473
|
+
if (!silent) toast('已保存');
|
|
474
|
+
}
|
|
475
|
+
let __biliQrTimer = null;
|
|
476
|
+
function biliFaceUrl(u){
|
|
477
|
+
const t = (function(){ try { return localStorage.getItem('token') || ''; } catch (e) { return ''; } })();
|
|
478
|
+
return '/api/bili/avatar?u=' + encodeURIComponent(u) + '&t=' + encodeURIComponent(t);
|
|
479
|
+
}
|
|
480
|
+
let BILI_MEM = [], BILI_MEM_PAGE = 0, BILI_OPEN = new Set();
|
|
481
|
+
const BILI_MEM_SIZE = 10;
|
|
482
|
+
function renderBiliMem(){
|
|
483
|
+
const box = document.getElementById('biliMemList');
|
|
484
|
+
if (!box) return;
|
|
485
|
+
if (BILI_MEM.length === 0) { box.innerHTML = '<div style="color:#7d8794;font-size:12px;padding:6px">还没有记录</div>'; return; }
|
|
486
|
+
const pages = Math.max(1, Math.ceil(BILI_MEM.length / BILI_MEM_SIZE));
|
|
487
|
+
BILI_MEM_PAGE = Math.max(0, Math.min(pages - 1, BILI_MEM_PAGE));
|
|
488
|
+
const rows = BILI_MEM.slice(BILI_MEM_PAGE * BILI_MEM_SIZE, BILI_MEM_PAGE * BILI_MEM_SIZE + BILI_MEM_SIZE);
|
|
489
|
+
box.innerHTML = rows.map(function(x){
|
|
490
|
+
const bv = String(x.bvid || '');
|
|
491
|
+
const open = BILI_OPEN.has(bv);
|
|
492
|
+
const summary = String(x.summary || '');
|
|
493
|
+
const reaction = String(x.reaction || '');
|
|
494
|
+
return '<div style="padding:6px 4px;border-bottom:1px solid rgba(255,255,255,.05)">'
|
|
495
|
+
+ '<div style="display:flex;gap:8px;align-items:center">'
|
|
496
|
+
+ '<span data-exp="' + esc(bv) + '" style="cursor:pointer;color:#8a93a0;width:12px;flex:none;user-select:none">' + (open ? '▾' : '▸') + '</span>'
|
|
497
|
+
+ '<a href="' + esc(String(x.url || '')) + '" target="_blank" style="flex:1;min-width:0;color:#9fc4ff;font-size:12.5px;text-decoration:none;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">' + esc(String(x.title || bv)) + '</a>'
|
|
498
|
+
+ '<button data-del="' + esc(bv) + '" style="background:none;border:none;color:#e05555;font-size:16px;cursor:pointer;padding:0 4px;line-height:1">×</button>'
|
|
499
|
+
+ '</div>'
|
|
500
|
+
+ '<div style="color:#7d8794;font-size:11px;margin-left:20px">' + esc((x.tags || []).join(' / ')) + '</div>'
|
|
501
|
+
+ (open ? '<div style="margin:6px 0 0 20px;color:#b9c2cf;font-size:12px;line-height:1.6;white-space:pre-wrap">' + ((summary || reaction) ? (esc(summary) + (reaction ? '\\n—— ' + esc(reaction) : '')) : '(还没有摘要)') + '</div>' : '')
|
|
502
|
+
+ '</div>';
|
|
503
|
+
}).join('');
|
|
504
|
+
const pager = document.getElementById('biliMemPager');
|
|
505
|
+
if (pager) {
|
|
506
|
+
pager.innerHTML = BILI_MEM.length > 0
|
|
507
|
+
? '<button class="ghost" data-pg="prev"' + (BILI_MEM_PAGE <= 0 ? ' disabled' : '') + '>‹</button><span style="color:#8a93a0;font-size:12px">' + (BILI_MEM_PAGE + 1) + ' / ' + pages + '</span><button class="ghost" data-pg="next"' + (BILI_MEM_PAGE >= pages - 1 ? ' disabled' : '') + '>›</button>'
|
|
508
|
+
: '';
|
|
509
|
+
pager.querySelectorAll('[data-pg]').forEach(function(b){ b.onclick = function(){ BILI_MEM_PAGE += (b.getAttribute('data-pg') === 'next' ? 1 : -1); renderBiliMem(); }; });
|
|
510
|
+
}
|
|
511
|
+
box.querySelectorAll('[data-exp]').forEach(function(el){ el.onclick = function(){ const bv = el.getAttribute('data-exp'); if (BILI_OPEN.has(bv)) BILI_OPEN.delete(bv); else BILI_OPEN.add(bv); renderBiliMem(); }; });
|
|
512
|
+
box.querySelectorAll('[data-del]').forEach(function(b){ b.onclick = function(){ delBiliMem(b.getAttribute('data-del')); }; });
|
|
513
|
+
}
|
|
514
|
+
async function loadBiliMem(){
|
|
515
|
+
try {
|
|
516
|
+
BILI_MEM = await api('/api/bili/memories');
|
|
517
|
+
BILI_MEM_PAGE = 0;
|
|
518
|
+
renderBiliMem();
|
|
519
|
+
} catch (e) {}
|
|
520
|
+
}
|
|
521
|
+
async function delBiliMem(bv){
|
|
522
|
+
try { await api('/api/bili/memories', { method:'DELETE', body: JSON.stringify({ bvid: bv }) }); toast('已删除'); loadBiliMem(); } catch (e) { toast('删除失败'); }
|
|
523
|
+
}
|
|
524
|
+
async function loadQqQr(){
|
|
525
|
+
try {
|
|
526
|
+
const img = document.getElementById('qqQrImg');
|
|
527
|
+
const tip = document.getElementById('qqQrTip');
|
|
528
|
+
if (!img) return;
|
|
529
|
+
try {
|
|
530
|
+
const st = await api('/api/state');
|
|
531
|
+
if (st && st.status && st.status.napcat) {
|
|
532
|
+
if (window.__qqQrTimer) { clearInterval(window.__qqQrTimer); window.__qqQrTimer = null; }
|
|
533
|
+
img.style.display = 'none';
|
|
534
|
+
if (tip) tip.textContent = 'QQ 已在线';
|
|
535
|
+
try {
|
|
536
|
+
const me = await api('/api/self');
|
|
537
|
+
const box = document.getElementById('qqSelfBox');
|
|
538
|
+
const av = document.getElementById('qqSelfAvatar');
|
|
539
|
+
const idEl = document.getElementById('qqSelfId');
|
|
540
|
+
if (me && me.id) {
|
|
541
|
+
if (box) box.style.display = 'flex';
|
|
542
|
+
if (av) av.src = 'https://q1.qlogo.cn/g?b=sdk&nk=' + encodeURIComponent(me.id) + '&s=100';
|
|
543
|
+
if (idEl) idEl.textContent = (me.nick ? me.nick + ' · ' : '') + me.id;
|
|
544
|
+
}
|
|
545
|
+
} catch (e) {}
|
|
546
|
+
return;
|
|
547
|
+
}
|
|
548
|
+
const sb = document.getElementById('qqSelfBox');
|
|
549
|
+
if (sb) sb.style.display = 'none';
|
|
550
|
+
} catch (e) {}
|
|
551
|
+
img.style.display = 'block';
|
|
552
|
+
img.src = '/qr?t=' + encodeURIComponent(T) + '&fresh=1&x=' + Date.now();
|
|
553
|
+
if (window.__qqQrTimer) clearInterval(window.__qqQrTimer);
|
|
554
|
+
window.__qqQrTimer = setInterval(function(){ try { img.src = '/qr?t=' + encodeURIComponent(T) + '&fresh=1&x=' + Date.now(); } catch (e) {} }, 120000);
|
|
555
|
+
if (tip) tip.textContent = '二维码每 20 秒自动更新';
|
|
556
|
+
img.onerror = function(){ if (tip) tip.textContent = '取不到二维码,检查 NapCat 是否在线'; };
|
|
557
|
+
img.onload = function(){ if (tip) tip.textContent = '扫码后用手机确认'; };
|
|
558
|
+
} catch (e) {}
|
|
559
|
+
}
|
|
560
|
+
function __debounce(fn, ms){
|
|
561
|
+
let t = null;
|
|
562
|
+
return function(){ const args = arguments; if (t) clearTimeout(t); t = setTimeout(function(){ t = null; fn.apply(null, args); }, ms); };
|
|
563
|
+
}
|
|
564
|
+
function bindAutoSave(){
|
|
565
|
+
try {
|
|
566
|
+
const saveSet = __debounce(function(){ saveSettings(true).then(function(){ try { if (LIST_LOADED) return saveAdmins(); } catch (e) {} }).catch(function(){}); }, 700);
|
|
567
|
+
document.querySelectorAll('#settingsFields input, #settingsFields select, #settingsFields textarea').forEach(function(el){
|
|
568
|
+
el.addEventListener('change', saveSet);
|
|
569
|
+
el.addEventListener('input', saveSet);
|
|
570
|
+
});
|
|
571
|
+
if (FILES && FILES.length) {
|
|
572
|
+
FILES.forEach(function(f){
|
|
573
|
+
const ta = document.getElementById('ta-' + f.key);
|
|
574
|
+
if (!ta) return;
|
|
575
|
+
const fn = __debounce(function(){ saveFile(f.name, f.key, true); }, 1200);
|
|
576
|
+
ta.addEventListener('input', fn);
|
|
577
|
+
ta.addEventListener('change', fn);
|
|
578
|
+
});
|
|
579
|
+
}
|
|
580
|
+
const bn = document.getElementById('botNameInput');
|
|
581
|
+
if (bn) { const fn = __debounce(function(){ saveBotName(true); }, 900); bn.addEventListener('input', fn); }
|
|
582
|
+
const cfgMap = [['cfg-arkKey', saveConfig], ['px-proxyUrl', saveProxy], ['px-clashApi', saveProxy], ['px-clashGroup', saveProxy], ['tb-baiduCookie', saveTieba]];
|
|
583
|
+
cfgMap.forEach(function(pair){
|
|
584
|
+
const el = document.getElementById(pair[0]);
|
|
585
|
+
if (!el) return;
|
|
586
|
+
const fn = __debounce(function(){ try { pair[1](true); } catch (e) {} }, 900);
|
|
587
|
+
el.addEventListener('input', fn);
|
|
588
|
+
el.addEventListener('change', fn);
|
|
589
|
+
});
|
|
590
|
+
} catch (e) {}
|
|
591
|
+
}
|
|
592
|
+
async function loadBiliMe(){
|
|
593
|
+
try {
|
|
594
|
+
const r = await api('/api/bili/me');
|
|
595
|
+
const st = document.getElementById('biliQrStatus');
|
|
596
|
+
const nm = document.getElementById('biliName');
|
|
597
|
+
const av = document.getElementById('biliAvatar');
|
|
598
|
+
if (r && r.loggedIn) {
|
|
599
|
+
st.textContent = '已登录';
|
|
600
|
+
nm.textContent = r.name || '';
|
|
601
|
+
if (r.face) { av.src = biliFaceUrl(r.face); av.style.display = 'block'; }
|
|
602
|
+
} else {
|
|
603
|
+
st.textContent = '未登录';
|
|
604
|
+
nm.textContent = '';
|
|
605
|
+
av.style.display = 'none';
|
|
606
|
+
}
|
|
607
|
+
} catch (e) {}
|
|
608
|
+
}
|
|
609
|
+
async function biliQrStart(){
|
|
610
|
+
if (__biliQrTimer) { clearInterval(__biliQrTimer); __biliQrTimer = null; }
|
|
611
|
+
const box = document.getElementById('biliQrBox');
|
|
612
|
+
const img = document.getElementById('biliQrImg');
|
|
613
|
+
const st = document.getElementById('biliQrStatus');
|
|
614
|
+
st.textContent = '正在生成二维码…';
|
|
615
|
+
try {
|
|
616
|
+
const r = await api('/api/bili/qr/new', { method: 'POST', body: '{}' });
|
|
617
|
+
if (!r || !r.ok) throw new Error(r && r.error || '生成失败');
|
|
618
|
+
img.src = 'https://api.qrserver.com/v1/create-qr-code/?size=220x220&margin=8&data=' + encodeURIComponent(r.url);
|
|
619
|
+
box.style.display = 'block';
|
|
620
|
+
st.textContent = '请用 B站 App 扫码';
|
|
621
|
+
__biliQrTimer = setInterval(async function(){
|
|
622
|
+
try {
|
|
623
|
+
const q = await api('/api/bili/qr/poll?key=' + encodeURIComponent(r.key));
|
|
624
|
+
if (!q || !q.ok) return;
|
|
625
|
+
if (q.code === 86101) { st.textContent = '等待扫码…'; return; }
|
|
626
|
+
if (q.code === 86090) { st.textContent = '已扫码,请在手机上确认'; return; }
|
|
627
|
+
if (q.code === 0) {
|
|
628
|
+
clearInterval(__biliQrTimer); __biliQrTimer = null;
|
|
629
|
+
box.style.display = 'none';
|
|
630
|
+
st.textContent = '已登录';
|
|
631
|
+
const nm = document.getElementById('biliName');
|
|
632
|
+
if (nm) nm.textContent = q.name || '';
|
|
633
|
+
const av = document.getElementById('biliAvatar');
|
|
634
|
+
if (av && q.face) { av.src = biliFaceUrl(q.face); av.style.display = 'block'; }
|
|
635
|
+
toast('登录成功');
|
|
636
|
+
return;
|
|
637
|
+
}
|
|
638
|
+
if (q.code === 86038) { st.textContent = '二维码已过期,请重新生成'; clearInterval(__biliQrTimer); __biliQrTimer = null; }
|
|
639
|
+
} catch (e) {}
|
|
640
|
+
}, 2000);
|
|
641
|
+
} catch (e) { st.textContent = '生成失败:' + String(e); }
|
|
642
|
+
}
|
|
643
|
+
async function loadTieba(){
|
|
644
|
+
try {
|
|
645
|
+
const st = await api('/api/state');
|
|
646
|
+
const s = (st && st.settings) || {};
|
|
647
|
+
document.getElementById('tb-baiduCookie').value = s.baiduCookie || '';
|
|
648
|
+
} catch (e) { toast('读取失败'); }
|
|
649
|
+
}
|
|
650
|
+
async function saveTieba(silent){
|
|
651
|
+
await api('/api/settings', { method:'POST', body: JSON.stringify({ baiduCookie: document.getElementById('tb-baiduCookie').value.trim() }) });
|
|
652
|
+
if (!silent) toast('已保存');
|
|
653
|
+
}
|
|
654
|
+
async function saveAllConfig(){
|
|
655
|
+
await saveConfig();
|
|
656
|
+
await saveProxy();
|
|
657
|
+
await saveTieba();
|
|
658
|
+
toast('配置已保存');
|
|
659
|
+
}
|
|
660
|
+
async function saveConfig(silent){
|
|
661
|
+
const el = document.getElementById('cfg-arkKey');
|
|
662
|
+
if (!el) return;
|
|
663
|
+
await api('/api/settings', { method:'POST', body: JSON.stringify({ arkKey: el.value.trim() }) });
|
|
664
|
+
if (!silent) toast('已保存');
|
|
665
|
+
}
|
|
666
|
+
async function load(){
|
|
667
|
+
const s = await api('/api/state');
|
|
668
|
+
FILES = s.files;
|
|
669
|
+
document.getElementById('status').innerHTML = '<span class="sdot" style="background:'+(s.status.dsh==='active'?'#3ddc84':'#e05555')+'"></span><small>DSH '+(s.status.dsh==='active'?'在线':'离线')+'</small> <span class="sdot" style="background:'+(s.status.napcat?'#3ddc84':'#e05555')+'"></span><small>QQ '+(s.status.napcat?'在线':'离线')+'</small>';
|
|
670
|
+
const __fileTabs = FILES.map(f=>'<div class="tab" data-id="'+f.key+'" onclick="tab(\\''+f.key+'\\')">'+f.label+'</div>').join('');
|
|
671
|
+
document.getElementById('tabs').innerHTML =
|
|
672
|
+
'<div style="margin:12px 0 10px">'
|
|
673
|
+
+ '<div id="fgHead" style="display:flex;align-items:center;gap:7px;cursor:pointer;user-select:none;background:rgba(255,255,255,.075);border:1px solid rgba(255,255,255,.10);border-radius:9px;padding:12px;font-size:16px;font-weight:600;color:#e6edf6">'
|
|
674
|
+
+ '<span id="fgArrow" style="font-size:12px;color:#9aa4b2">▾</span><span>肥鱼底色</span>'
|
|
675
|
+
+ '<span id="fgHelp" style="margin-left:auto;display:inline-flex;width:18px;height:18px;border-radius:50%;border:1.5px solid #9aa4b2;align-items:center;justify-content:center;font-size:12px;font-weight:400;color:#9aa4b2;cursor:help">!</span>'
|
|
676
|
+
+ '</div>'
|
|
677
|
+
+ '<div id="fgTabs" style="margin-top:10px;display:flex;flex-direction:column;gap:0">' + __fileTabs + '</div>'
|
|
678
|
+
+ '</div>'
|
|
679
|
+
+ '<div style="margin:0 0 6px">'
|
|
680
|
+
+ '<div id="wqHead" style="display:flex;align-items:center;gap:7px;cursor:pointer;user-select:none;background:rgba(255,255,255,.075);border:1px solid rgba(255,255,255,.10);border-radius:9px;padding:12px;font-size:16px;font-weight:600;color:#e6edf6">'
|
|
681
|
+
+ '<span id="wqArrow" style="font-size:12px;color:#9aa4b2">▾</span><span>肥鱼库</span>'
|
|
682
|
+
+ '<span id="wqHelp" style="margin-left:auto;display:inline-flex;width:18px;height:18px;border-radius:50%;border:1.5px solid #9aa4b2;align-items:center;justify-content:center;font-size:12px;font-weight:400;color:#9aa4b2;cursor:help">!</span>'
|
|
683
|
+
+ '</div>'
|
|
684
|
+
+ '<div id="wqTabs" style="margin-top:10px;display:flex;flex-direction:column;gap:0">'
|
|
685
|
+
+ '<div class="tab" data-id="slang" onclick="tab(\\'slang\\')">黑话表<span class="tabhelp" data-hint="只收骂人、黑称,需解释;上限 40 条,满了删最旧。" style="margin-left:6px;display:inline-flex;width:15px;height:15px;border-radius:50%;border:1px solid #9aa4b2;align-items:center;justify-content:center;font-size:10px;color:#9aa4b2;cursor:help;vertical-align:middle">!</span></div>'
|
|
686
|
+
+ '<div class="tab" data-id="banned" onclick="tab(\\'banned\\')">禁词表<span class="tabhelp" data-hint="回复里要删的词,可与其他表重合;上限 40 条,满了删最旧。" style="margin-left:6px;display:inline-flex;width:15px;height:15px;border-radius:50%;border:1px solid #9aa4b2;align-items:center;justify-content:center;font-size:10px;color:#9aa4b2;cursor:help;vertical-align:middle">!</span></div>'
|
|
687
|
+
+ '<div class="tab" data-id="tics" onclick="tab(\\'tics\\')">语言模仿表<span class="tabhelp" data-hint="学对方说法(口头禅/句式),与黑话互斥;上限 80 条,每 30 次对话最多用 5 次。" style="margin-left:6px;display:inline-flex;width:15px;height:15px;border-radius:50%;border:1px solid #9aa4b2;align-items:center;justify-content:center;font-size:10px;color:#9aa4b2;cursor:help;vertical-align:middle">!</span></div>'
|
|
688
|
+
+ '<div class="tab" data-id="stickers" onclick="tab(\\'stickers\\')">表情库<span class="tabhelp" data-hint="存储上限:100 张,超了自动删最旧的;需要调整可以让 DSH 改。" style="margin-left:6px;display:inline-flex;width:15px;height:15px;border-radius:50%;border:1px solid #9aa4b2;align-items:center;justify-content:center;font-size:10px;color:#9aa4b2;cursor:help;vertical-align:middle">!</span></div>'
|
|
689
|
+
+ '<div class="tab" data-id="memes" onclick="tab(\\'memes\\')">梗百科/知识库<span class="tabhelp" data-hint="存储上限:10000 条,满了删最旧的;需要调整可以让 DSH 改。" style="margin-left:6px;display:inline-flex;width:15px;height:15px;border-radius:50%;border:1px solid #9aa4b2;align-items:center;justify-content:center;font-size:10px;color:#9aa4b2;cursor:help;vertical-align:middle">!</span></div>'
|
|
690
|
+
+ '</div>'
|
|
691
|
+
+ '</div>'
|
|
692
|
+
|
|
693
|
+
+ '<div style="margin:12px 0 10px">'
|
|
694
|
+
+ '<div id="cfgHead" style="display:flex;align-items:center;gap:7px;cursor:pointer;user-select:none;background:rgba(255,255,255,.075);border:1px solid rgba(255,255,255,.10);border-radius:9px;padding:12px;font-size:16px;font-weight:600;color:#e6edf6">'
|
|
695
|
+
+ '<span id="cfgArrow" style="font-size:12px;color:#9aa4b2">▾</span><span>功能配置</span>'
|
|
696
|
+
+ '<span id="cfgHelp" style="margin-left:auto;display:inline-flex;width:18px;height:18px;border-radius:50%;border:1.5px solid #9aa4b2;align-items:center;justify-content:center;font-size:12px;font-weight:400;color:#9aa4b2;cursor:help">!</span>'
|
|
697
|
+
+ '</div>'
|
|
698
|
+
+ '<div id="cfgTabs" style="margin-top:10px;display:flex;flex-direction:column;gap:0">'
|
|
699
|
+
+ '<div class="tab" data-id="config" onclick="tab(\\'config\\')">B站配置<span class="tabhelp" data-hint="观看记录最多 50 条,满了删最旧;九宫格图片只保留最近 20 张;视频文件抽完立即删除。卡片签名 Key 失效时卡片会退化成文字链接。" style="margin-left:6px;display:inline-flex;width:15px;height:15px;border-radius:50%;border:1px solid #9aa4b2;align-items:center;justify-content:center;font-size:10px;color:#9aa4b2;cursor:help;vertical-align:middle">!</span></div>'
|
|
700
|
+
+ '<div class="tab" data-id="proxy" onclick="tab(\\'proxy\\')">代理配置</div>'
|
|
701
|
+
+ '<div class="tab" data-id="tieba" onclick="tab(\\'tieba\\')">贴吧配置</div>'
|
|
702
|
+
+ '</div>'
|
|
703
|
+
+ '</div>'
|
|
704
|
+
+ '<div style="margin:12px 0 10px">'
|
|
705
|
+
+ '<div id="othHead" style="display:flex;align-items:center;gap:7px;cursor:pointer;user-select:none;background:rgba(255,255,255,.075);border:1px solid rgba(255,255,255,.10);border-radius:9px;padding:12px;font-size:16px;font-weight:600;color:#e6edf6">'
|
|
706
|
+
+ '<span id="othArrow" style="font-size:12px;color:#9aa4b2">▾</span><span>QQ控制</span>'
|
|
707
|
+
+ '<span id="othHelp" style="margin-left:auto;display:inline-flex;width:18px;height:18px;border-radius:50%;border:1.5px solid #9aa4b2;align-items:center;justify-content:center;font-size:12px;font-weight:400;color:#9aa4b2;cursor:help">!</span>'
|
|
708
|
+
+ '</div>'
|
|
709
|
+
+ '<div id="othTabs" style="margin-top:10px;display:flex;flex-direction:column;gap:0">'
|
|
710
|
+
+ '<div class="tab" data-id="settings" onclick="tab(\\'settings\\')">行为参数</div>'
|
|
711
|
+
+ '<div class="tab" data-id="chats" onclick="tab(\\'chats\\')">会话<span class="tabhelp" data-hint="上下文:会话满 30 轮或 40 万字符自动轮换;群聊缓存 30 条、每轮注入 30 条;摘要每个会话最多 9 段。" style="margin-left:6px;display:inline-flex;width:15px;height:15px;border-radius:50%;border:1px solid #9aa4b2;align-items:center;justify-content:center;font-size:10px;color:#9aa4b2;cursor:help;vertical-align:middle">!</span></div>'
|
|
712
|
+
+ '<div class="tab" data-id="reminders" onclick="tab(\\'reminders\\')">待办<span class="tabhelp" data-hint="待办全局最多 10 条,到点 @ 发起人;出现提醒最多 50 条。" style="margin-left:6px;display:inline-flex;width:15px;height:15px;border-radius:50%;border:1px solid #9aa4b2;align-items:center;justify-content:center;font-size:10px;color:#9aa4b2;cursor:help;vertical-align:middle">!</span></div>'
|
|
713
|
+
+ '<div class="tab" data-id="contacts" onclick="tab(\\'contacts\\')">社交<span class="tabhelp" data-hint="好感度 0–100:≥75 卖萌、≤25 暴躁、≤10 极端;长期记忆全局最多 800 条,锁定后不会被自动优化。 认人规则:和同一个人聊满 10 次还没记名字,会主动问一次称呼(只问一次)。 档案(名字/称呼/简介)写入后,本人可以让它改;管理员可以改别人的。" style="margin-left:6px;display:inline-flex;width:15px;height:15px;border-radius:50%;border:1px solid #9aa4b2;align-items:center;justify-content:center;font-size:10px;color:#9aa4b2;cursor:help;vertical-align:middle">!</span></div>'
|
|
714
|
+
;
|
|
715
|
+
+ '</div>'
|
|
716
|
+
+ '</div>'
|
|
717
|
+
+ '<div style="margin:14px 0 10px">'
|
|
718
|
+
+ '<div class="tab" id="logsHead" data-id="logs" onclick="tab(\\'logs\\')" style="display:flex;align-items:center;gap:7px;background:rgba(255,255,255,.075);border:1px solid rgba(255,255,255,.10);border-radius:9px;padding:12px;font-size:16px;font-weight:600;color:#e6edf6">运行日志</div>'
|
|
719
|
+
+ '</div>'
|
|
720
|
+
document.getElementById('panels').innerHTML = FILES.map(function(f){
|
|
721
|
+
const __nameBox = f.name === '1-persona.md'
|
|
722
|
+
? '<div class="row" style="gap:6px;margin-bottom:8px"><input id="botNameInput" placeholder="机器人名字,多个用逗号分隔" style="flex:1"><button onclick="saveBotName()">保存名字</button></div>'
|
|
723
|
+
: '';
|
|
724
|
+
return '<div class="panel" id="p-'+f.key+'">' + __nameBox + '<textarea id="ta-'+f.key+'"></textarea>'
|
|
725
|
+
+ '</div>';
|
|
726
|
+
}).join('') + '<div class="panel" id="p-config">'
|
|
727
|
+
+ '<div style="background:rgba(255,255,255,.035);border:1px solid rgba(255,255,255,.07);border-radius:12px;padding:16px">'
|
|
728
|
+
+ '<h4 style="margin:0 0 10px;font-size:13px;color:#9aa4b2">卡片签名 Key</h4>'
|
|
729
|
+
+ '<input id="cfg-arkKey" type="text" placeholder="sk-...">'
|
|
730
|
+
+ '<div style="color:#5f6875;font-size:11px;margin:6px 0 18px">发 B 站、网易云卡片需要签名;没有的话去 https://api.xianyu.cn 注册一个 sk- 开头的。</div>'
|
|
731
|
+
+ '<h4 style="margin:0 0 10px;font-size:13px;color:#9aa4b2">B站登录</h4>'
|
|
732
|
+
+ '<div id="biliQrBox" style="display:none;margin:0 0 10px"><img id="biliQrImg" alt="二维码" style="width:200px;height:200px;background:#fff;border-radius:8px;padding:6px"></div>'
|
|
733
|
+
+ '<div style="display:flex;align-items:center;gap:10px;margin:0 0 10px">'
|
|
734
|
+
+ '<img id="biliAvatar" alt="" style="display:none;width:36px;height:36px;border-radius:50%;object-fit:cover;background:#0b0f14">'
|
|
735
|
+
+ '<div><div id="biliQrStatus" style="color:#8a93a0;font-size:12px">检查登录状态…</div><div id="biliName" style="font-size:13px;color:#dbe3ee"></div></div>'
|
|
736
|
+
+ '</div>'
|
|
737
|
+
+ '<button onclick="biliQrStart()">扫码登录</button>'
|
|
738
|
+
+ '<h4 style="margin:18px 0 8px;font-size:13px;color:#9aa4b2">观看记录</h4>'
|
|
739
|
+
+ '<div id="biliMemList" style="max-height:260px;overflow:auto;border:1px solid rgba(255,255,255,.07);border-radius:8px;padding:6px"><div style="color:#7d8794;font-size:12px;padding:6px">还没有记录</div></div>'
|
|
740
|
+
+ '<div id="biliMemPager" style="display:flex;align-items:center;gap:8px;margin-top:6px"></div>'
|
|
741
|
+
+ '</div></div>'
|
|
742
|
+
+ '<div class="panel" id="p-proxy"><div style="background:rgba(255,255,255,.035);border:1px solid rgba(255,255,255,.07);border-radius:12px;padding:16px">'
|
|
743
|
+
+ '<h4 style="margin:0 0 10px;font-size:13px;color:#9aa4b2">代理地址</h4>'
|
|
744
|
+
+ '<input id="px-proxyUrl" type="text" placeholder="http://127.0.0.1:7890">'
|
|
745
|
+
+ '<div style="color:#5f6875;font-size:11px;margin:6px 0 16px">不填就直连,搜图和外链图片可能失败。</div>'
|
|
746
|
+
+ '<h4 style="margin:0 0 10px;font-size:13px;color:#9aa4b2">Clash API</h4>'
|
|
747
|
+
+ '<input id="px-clashApi" type="text" placeholder="http://127.0.0.1:9090">'
|
|
748
|
+
+ '<div style="color:#5f6875;font-size:11px;margin:6px 0 16px">填了才自动切换节点,不填就不切。</div>'
|
|
749
|
+
+ '<h4 style="margin:0 0 10px;font-size:13px;color:#9aa4b2">Clash 分组名</h4>'
|
|
750
|
+
+ '<input id="px-clashGroup" type="text" placeholder="机场分组名">'
|
|
751
|
+
+ '<div style="color:#5f6875;font-size:11px;margin:6px 0 16px">填你自己机场的分组,配合 Clash API 用。</div>'
|
|
752
|
+
+ '<div style="color:#e05555;font-size:12px;margin:0 0 4px">有订阅链接的话,直接甩给 DSH,让它帮你配好</div>'
|
|
753
|
+
+ '</div></div>'
|
|
754
|
+
+ '<div class="panel" id="p-tieba"><div style="background:rgba(255,255,255,.035);border:1px solid rgba(255,255,255,.07);border-radius:12px;padding:16px">'
|
|
755
|
+
+ '<h4 style="margin:0 0 10px;font-size:13px;color:#9aa4b2">百度 Cookie</h4>'
|
|
756
|
+
+ '<textarea id="tb-baiduCookie" rows="2" style="width:100%;box-sizing:border-box;font-size:12px;min-height:52px;resize:vertical"></textarea>'
|
|
757
|
+
+ '<div style="color:#5f6875;font-size:11px;margin:6px 0 16px">抓贴吧链接时带上它,内容更全;主要要 BDUSS、STOKEN,不填也能用。</div>'
|
|
758
|
+
+ '</div></div>'
|
|
759
|
+
;
|
|
760
|
+
try { loadBiliMe(); } catch {}
|
|
761
|
+
try {
|
|
762
|
+
const __st = s.settings || {};
|
|
763
|
+
const __turns = typeof __st.sessionWindowTurns === 'number' ? __st.sessionWindowTurns : 30;
|
|
764
|
+
const __chars = typeof __st.sessionCharLimit === 'number' ? __st.sessionCharLimit : 400000;
|
|
765
|
+
const __chatHelp = document.querySelector('.tabhelp[data-hint^="上下文"]');
|
|
766
|
+
if (__chatHelp) __chatHelp.dataset.hint = '上下文:会话满 ' + __turns + ' 轮或 ' + Math.round(__chars / 10000) + ' 万字符自动轮换;群聊缓存 30 条、每轮注入 30 条;摘要每个会话最多 9 段。';
|
|
767
|
+
} catch {}
|
|
768
|
+
for (const f of FILES) document.getElementById('ta-'+f.key).value = f.text;
|
|
769
|
+
try {
|
|
770
|
+
const __bn = document.getElementById('botNameInput');
|
|
771
|
+
if (__bn) __bn.value = (s.botNames || []).join(',');
|
|
772
|
+
} catch {}
|
|
773
|
+
try {
|
|
774
|
+
if (!window.__tabHelpTip) {
|
|
775
|
+
const __tip = document.createElement('div');
|
|
776
|
+
__tip.style.cssText = 'position:fixed;z-index:99;display:none;background:#1b222b;border:1px solid rgba(255,255,255,.14);color:#cfd8e3;font-size:12px;line-height:1.6;padding:8px 11px;border-radius:8px;box-shadow:0 8px 30px rgba(0,0,0,.5);max-width:300px;white-space:pre-line';
|
|
777
|
+
document.body.appendChild(__tip);
|
|
778
|
+
window.__tabHelpTip = __tip;
|
|
779
|
+
}
|
|
780
|
+
const __tip = window.__tabHelpTip;
|
|
781
|
+
document.querySelectorAll('.tabhelp').forEach(function(el){
|
|
782
|
+
el.onmouseenter = function(e){
|
|
783
|
+
e.stopPropagation();
|
|
784
|
+
const r = el.getBoundingClientRect();
|
|
785
|
+
__tip.textContent = el.dataset.hint || '';
|
|
786
|
+
__tip.style.display = 'block';
|
|
787
|
+
__tip.style.top = Math.min(window.innerHeight - 80, r.bottom + 6) + 'px';
|
|
788
|
+
__tip.style.left = Math.max(8, Math.min(window.innerWidth - 320, r.right + 8)) + 'px';
|
|
789
|
+
};
|
|
790
|
+
el.onmouseleave = function(){ __tip.style.display = 'none'; };
|
|
791
|
+
el.onclick = function(e){ e.stopPropagation(); };
|
|
792
|
+
});
|
|
793
|
+
} catch {}
|
|
794
|
+
renderSettings(s.settings);
|
|
795
|
+
try { bindAutoSave(); } catch {}
|
|
796
|
+
STICKERS = s.stickers;
|
|
797
|
+
renderGrid();
|
|
798
|
+
try {
|
|
799
|
+
const __head = document.getElementById('fgHead');
|
|
800
|
+
const __body = document.getElementById('fgTabs');
|
|
801
|
+
const __arrow = document.getElementById('fgArrow');
|
|
802
|
+
let __open = true;
|
|
803
|
+
try { __open = localStorage.getItem('fgOpen') !== '0'; } catch {}
|
|
804
|
+
const __apply = function(){ __body.style.display = __open ? 'block' : 'none'; __arrow.textContent = __open ? '▾' : '▸'; };
|
|
805
|
+
__apply();
|
|
806
|
+
__head.onclick = function(){ __open = !__open; try { localStorage.setItem('fgOpen', __open ? '1' : '0'); } catch {} __apply(); };
|
|
807
|
+
const __help = document.getElementById('fgHelp');
|
|
808
|
+
const __tip = document.createElement('div');
|
|
809
|
+
__tip.style.cssText = 'position:fixed;z-index:99;display:none;background:#1b222b;border:1px solid rgba(255,255,255,.14);color:#cfd8e3;font-size:12px;line-height:1.6;padding:8px 11px;border-radius:8px;box-shadow:0 8px 30px rgba(0,0,0,.5);white-space:normal;max-width:300px';
|
|
810
|
+
__tip.textContent = '除了人设,其他规则建议不动';
|
|
811
|
+
document.body.appendChild(__tip);
|
|
812
|
+
__help.onmouseenter = function(){
|
|
813
|
+
const r = __help.getBoundingClientRect();
|
|
814
|
+
__tip.style.display = 'block';
|
|
815
|
+
__tip.style.top = (r.bottom + 6) + 'px';
|
|
816
|
+
__tip.style.left = Math.max(8, Math.min(window.innerWidth - 220, r.left - 20)) + 'px';
|
|
817
|
+
};
|
|
818
|
+
__help.onmouseleave = function(){ __tip.style.display = 'none'; };
|
|
819
|
+
__help.onclick = function(e){ e.stopPropagation(); };
|
|
820
|
+
} catch {}
|
|
821
|
+
try {
|
|
822
|
+
const __wh = document.getElementById('wqHead');
|
|
823
|
+
const __wb = document.getElementById('wqTabs');
|
|
824
|
+
const __wa = document.getElementById('wqArrow');
|
|
825
|
+
let __wopen = true;
|
|
826
|
+
try { __wopen = localStorage.getItem('wqOpen') !== '0'; } catch {}
|
|
827
|
+
const __wapply = function(){ __wb.style.display = __wopen ? 'block' : 'none'; __wa.textContent = __wopen ? '▾' : '▸'; };
|
|
828
|
+
__wapply();
|
|
829
|
+
__wh.onclick = function(){ __wopen = !__wopen; try { localStorage.setItem('wqOpen', __wopen ? '1' : '0'); } catch {} __wapply(); };
|
|
830
|
+
const __whelp = document.getElementById('wqHelp');
|
|
831
|
+
const __wtip = document.createElement('div');
|
|
832
|
+
__wtip.style.cssText = 'position:fixed;z-index:99;display:none;background:#1b222b;border:1px solid rgba(255,255,255,.14);color:#cfd8e3;font-size:12px;line-height:1.6;padding:8px 11px;border-radius:8px;box-shadow:0 8px 30px rgba(0,0,0,.5);white-space:nowrap';
|
|
833
|
+
__wtip.textContent = 'bot 有自学习 / 自动存表情功能,用户也可以在聊天里教学,bot 会自动分流到下面这些表(梗百科/知识库除外)';
|
|
834
|
+
document.body.appendChild(__wtip);
|
|
835
|
+
__whelp.onmouseenter = function(){
|
|
836
|
+
const r = __whelp.getBoundingClientRect();
|
|
837
|
+
__wtip.style.display = 'block';
|
|
838
|
+
__wtip.style.top = (r.bottom + 6) + 'px';
|
|
839
|
+
__wtip.style.left = Math.max(8, Math.min(window.innerWidth - 240, r.left - 20)) + 'px';
|
|
840
|
+
};
|
|
841
|
+
__whelp.onmouseleave = function(){ __wtip.style.display = 'none'; };
|
|
842
|
+
__whelp.onclick = function(e){ e.stopPropagation(); };
|
|
843
|
+
} catch {}
|
|
844
|
+
try {
|
|
845
|
+
const __oh = document.getElementById('othHead');
|
|
846
|
+
const __ob = document.getElementById('othTabs');
|
|
847
|
+
const __oa = document.getElementById('othArrow');
|
|
848
|
+
let __oopen = true;
|
|
849
|
+
try { __oopen = localStorage.getItem('othOpen') !== '0'; } catch {}
|
|
850
|
+
const __oapply = function(){ __ob.style.display = __oopen ? 'block' : 'none'; __oa.textContent = __oopen ? '▾' : '▸'; };
|
|
851
|
+
__oapply();
|
|
852
|
+
__oh.onclick = function(){ __oopen = !__oopen; try { localStorage.setItem('othOpen', __oopen ? '1' : '0'); } catch {} __oapply(); };
|
|
853
|
+
try {
|
|
854
|
+
const __ohhelp = document.getElementById('othHelp');
|
|
855
|
+
if (__ohhelp) {
|
|
856
|
+
const __ohtip = document.createElement('div');
|
|
857
|
+
__ohtip.style.cssText = 'position:fixed;z-index:99;display:none;background:#1b222b;border:1px solid rgba(255,255,255,.14);color:#cfd8e3;font-size:12px;line-height:1.6;padding:8px 11px;border-radius:8px;box-shadow:0 8px 30px rgba(0,0,0,.5);max-width:260px';
|
|
858
|
+
__ohtip.textContent = '谨慎改动行为参数:它会影响回复频率、节奏和触发条件,拿不准就保持默认';
|
|
859
|
+
document.body.appendChild(__ohtip);
|
|
860
|
+
__ohhelp.onmouseenter = function(){
|
|
861
|
+
const r = __ohhelp.getBoundingClientRect();
|
|
862
|
+
__ohtip.style.display = 'block';
|
|
863
|
+
__ohtip.style.top = (r.bottom + 6) + 'px';
|
|
864
|
+
__ohtip.style.left = Math.max(8, Math.min(window.innerWidth - 280, r.left - 200)) + 'px';
|
|
865
|
+
};
|
|
866
|
+
__ohhelp.onmouseleave = function(){ __ohtip.style.display = 'none'; };
|
|
867
|
+
__ohhelp.onclick = function(e){ e.stopPropagation(); };
|
|
868
|
+
}
|
|
869
|
+
} catch {}
|
|
870
|
+
} catch {}
|
|
871
|
+
try {
|
|
872
|
+
const __ch = document.getElementById('cfgHead');
|
|
873
|
+
const __cb = document.getElementById('cfgTabs');
|
|
874
|
+
const __ca = document.getElementById('cfgArrow');
|
|
875
|
+
if (__ch && __cb) {
|
|
876
|
+
let __copen = true;
|
|
877
|
+
try { __copen = localStorage.getItem('cfgOpen') !== '0'; } catch {}
|
|
878
|
+
const __capply = function(){ __cb.style.display = __copen ? 'block' : 'none'; if (__ca) __ca.textContent = __copen ? '▾' : '▸'; };
|
|
879
|
+
__capply();
|
|
880
|
+
__ch.onclick = function(){ __copen = !__copen; try { localStorage.setItem('cfgOpen', __copen ? '1' : '0'); } catch {} __capply(); };
|
|
881
|
+
}
|
|
882
|
+
} catch {}
|
|
883
|
+
try {
|
|
884
|
+
const __mb = document.getElementById('memeHelpBtn');
|
|
885
|
+
const __mp = document.getElementById('memeHelp');
|
|
886
|
+
if (__mb && __mp) {
|
|
887
|
+
const __showM = function(){
|
|
888
|
+
const r = __mb.getBoundingClientRect();
|
|
889
|
+
__mp.style.display = 'block';
|
|
890
|
+
__mp.style.top = (r.bottom + 8) + 'px';
|
|
891
|
+
__mp.style.left = Math.max(8, Math.min(window.innerWidth - 540, r.left - 10)) + 'px';
|
|
892
|
+
};
|
|
893
|
+
__mb.onmouseenter = __showM;
|
|
894
|
+
__mb.onmouseleave = function(){ __mp.style.display = 'none'; };
|
|
895
|
+
__mb.onclick = __showM;
|
|
896
|
+
}
|
|
897
|
+
} catch {}
|
|
898
|
+
try {
|
|
899
|
+
const __rb2 = document.getElementById('remindHelpBtn');
|
|
900
|
+
const __rp2 = document.getElementById('remindHelp');
|
|
901
|
+
if (__rb2 && __rp2) {
|
|
902
|
+
const __showR2 = function(){
|
|
903
|
+
const r = __rb2.getBoundingClientRect();
|
|
904
|
+
__rp2.style.display = 'block';
|
|
905
|
+
__rp2.style.top = (r.bottom + 8) + 'px';
|
|
906
|
+
__rp2.style.left = Math.max(8, Math.min(window.innerWidth - 540, r.left - 10)) + 'px';
|
|
907
|
+
};
|
|
908
|
+
__rb2.onmouseenter = __showR2;
|
|
909
|
+
__rb2.onmouseleave = function(){ __rp2.style.display = 'none'; };
|
|
910
|
+
__rb2.onclick = __showR2;
|
|
911
|
+
}
|
|
912
|
+
} catch {}
|
|
913
|
+
try {
|
|
914
|
+
const __cb = document.getElementById('chatHelpBtn');
|
|
915
|
+
const __cp = document.getElementById('chatHelp');
|
|
916
|
+
if (__cb && __cp) {
|
|
917
|
+
const __showC = function(){
|
|
918
|
+
const r = __cb.getBoundingClientRect();
|
|
919
|
+
__cp.style.display = 'block';
|
|
920
|
+
__cp.style.top = (r.bottom + 8) + 'px';
|
|
921
|
+
__cp.style.left = Math.max(8, Math.min(window.innerWidth - 580, r.left - 10)) + 'px';
|
|
922
|
+
};
|
|
923
|
+
__cb.onmouseenter = __showC;
|
|
924
|
+
__cb.onmouseleave = function(){ __cp.style.display = 'none'; };
|
|
925
|
+
__cb.onclick = __showC;
|
|
926
|
+
}
|
|
927
|
+
} catch {}
|
|
928
|
+
|
|
929
|
+
tab(FILES.length? FILES[0].key : 'settings');
|
|
930
|
+
}
|
|
931
|
+
async function saveBotName(silent){
|
|
932
|
+
const el = document.getElementById('botNameInput');
|
|
933
|
+
if (!el) return;
|
|
934
|
+
const names = String(el.value || '').split(/[,,、\s]+/).map(function(v){ return v.trim(); }).filter(Boolean);
|
|
935
|
+
if (!names.length) { if (!silent) toast('名字不能空'); return; }
|
|
936
|
+
const r = await api('/api/botname', { method:'POST', body: JSON.stringify({ names: names }) });
|
|
937
|
+
if (r && r.ok) { el.value = r.botNames.join(','); if (!silent) toast('名字已保存'); }
|
|
938
|
+
else toast('保存失败');
|
|
939
|
+
}
|
|
940
|
+
async function saveFile(name, key, silent){
|
|
941
|
+
await api('/api/file',{method:'POST',body:JSON.stringify({name,text:document.getElementById('ta-'+key).value})});
|
|
942
|
+
if (!silent) toast('已保存 '+name);
|
|
943
|
+
}
|
|
944
|
+
const SETTINGS_FIELDS = [
|
|
945
|
+
{ key:'replyMaxParts', label:'最多连发条数', type:'range', min:2, max:10, step:1, unit:'条', hint:'一次回复最多拆成几条消息发出去:60% 只发两条,其余随机发三条到上限' },
|
|
946
|
+
{ key:'replyPartChars', label:'每条最多字数', type:'range', min:4, max:40, step:1, unit:'字', hint:'单条消息超过这个字数会被拆成多条,太短会显得断断续续' },
|
|
947
|
+
{ key:'sendGapMs', label:'连发间隔', type:'range', scale:0.001, min:0, max:3, step:0.1, unit:'秒', hint:'大肥鱼一次回复发好几条时,每条之间的间隔' },
|
|
948
|
+
{ key:'messageBufferMs', label:'回复缓冲', type:'range', scale:0.001, min:0, max:10, step:0.1, unit:'秒', hint:'他每发一条就重新计时,隔这么久没新消息才回复;连发会合并成一条' },
|
|
949
|
+
{ key:'groupFollowUpSeconds', label:'接话窗口', type:'range', min:60, max:600, step:30, unit:'秒', hint:'超过这个时间没发消息会关闭对话窗口;最少 1 分钟(主动找话在窗口中间判定)' },
|
|
950
|
+
{ key:'replyBudgetPerMin', label:'每分钟最多回复', type:'range', min:1, max:20, step:1, unit:'条', hint:'一分钟内最多回几次;超过就不回,这期间 @ 它、戳它或叫它名字,它会回一句「话太多了」' },
|
|
951
|
+
{ key:'stickerChance', label:'表情概率', type:'range', scale:100, min:0, max:100, step:5, unit:'%', hint:'每次回复时顺手带一张表情的概率' },
|
|
952
|
+
{ key:'groupAmbientChance', label:'接话概率', type:'range', scale:100, min:0, max:100, step:1, unit:'%', hint:'群里没人叫它时,它自己插话的概率' },
|
|
953
|
+
{ key:'knownChance', label:'认识的人接话概率', type:'range', scale:100, min:0, max:100, step:5, unit:'%', def:0.5, hint:'社交里的人在群里说话、但没叫它时,它主动接话的概率;一般比接话概率高' },
|
|
954
|
+
{ key:'proactiveChance', label:'主动找话概率', type:'range', scale:100, min:0, max:100, step:5, unit:'%', hint:'和某人聊着、对方不回了,聊天窗口走到一半时,按这个概率决定要不要主动说一句;说完不续期' },
|
|
955
|
+
{ key:'chatVideoChance', label:'聊天中发视频概率', type:'range', scale:100, min:0, max:100, step:5, unit:'%', hint:'聊天时随机发视频的概率' },
|
|
956
|
+
{ key:'moodCooldownTurns', label:'好感度切档冷却', type:'range', min:0, max:20, step:1, unit:'次', hint:'好感度升档或降档后,至少隔几次才允许再变,避免态度反复' },
|
|
957
|
+
{ key:'sessionWindowTurns', label:'会话次数上限', type:'range', min:10, max:100, step:5, unit:'次', hint:'一段对话满多少次就换新的,旧内容不再带入' },
|
|
958
|
+
{ key:'pokeReplies', label:'戳一戳回复', type:'text', hint:'被戳时随机回一句,逗号分开' },
|
|
959
|
+
{ key:'sessionCharLimit', label:'会话字符上限', type:'range', min:50000, max:1000000, step:50000, unit:'字符', hint:'对话内容累计超过多少字符就换新的' },
|
|
960
|
+
{ key:'arkKey', label:'卡片签名 Key', type:'text', long:true, hint:'发 B 站、网易云卡片需要签名,填签名服务的 Key;没有的话去 https://apii.xianyuw.cn 注册拿一个 sk- 开头的' },
|
|
961
|
+
{ key:'biliCookie', label:'B站 Cookie', type:'text', long:true, hint:'想让它查 B 站信息更稳,可从浏览器复制 Cookie;不会弄可以叫 DSH 帮你弄' },
|
|
962
|
+
{ key:'proxyUrl', label:'代理地址', type:'text', long:true, hint:'例如 http://127.0.0.1:7890;不填就直连,搜图和外链图片可能失败' },
|
|
963
|
+
{ key:'clashApi', label:'Clash API', type:'text', long:true, hint:'例如 http://127.0.0.1:9090;填了才自动切换节点,不填就不切' },
|
|
964
|
+
{ key:'clashGroup', label:'Clash 分组名', type:'text', long:true, hint:'机场分组名,填你自己机场的分组;配合 Clash API 使用' },
|
|
965
|
+
];
|
|
966
|
+
async function loadSlang(){
|
|
967
|
+
const map = await api('/api/slang');
|
|
968
|
+
const allKeys = Object.keys(map);
|
|
969
|
+
const SIZE = 8;
|
|
970
|
+
const pages = Math.max(1, Math.ceil(allKeys.length / SIZE));
|
|
971
|
+
PAGE_STATE.slang = Math.max(0, Math.min(pages - 1, PAGE_STATE.slang));
|
|
972
|
+
const keys = allKeys.slice(PAGE_STATE.slang * SIZE, PAGE_STATE.slang * SIZE + SIZE);
|
|
973
|
+
renderPager('slangPager', 'slang', PAGE_STATE.slang, allKeys.length, SIZE, loadSlang);
|
|
974
|
+
const box = document.getElementById('slangList');
|
|
975
|
+
box.innerHTML = keys.length
|
|
976
|
+
? '<div style="display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:8px">'
|
|
977
|
+
+ keys.map(function(k){
|
|
978
|
+
return '<div class="chatcard" style="margin:0"><div class="cbody" style="min-width:0"><div class="row" style="gap:6px;align-items:center">'
|
|
979
|
+
+ '<input data-old="'+esc(k)+'" value="'+esc(k)+'" style="width:88px;min-width:0">'
|
|
980
|
+
+ '<span style="opacity:.6">→</span>'
|
|
981
|
+
+ '<input data-mean="'+esc(k)+'" value="'+esc(map[k])+'" style="flex:1;min-width:0">'
|
|
982
|
+
+ '<button data-save="'+esc(k)+'">保存</button>'
|
|
983
|
+
+ '<button class="danger" data-del="'+esc(k)+'">删</button>'
|
|
984
|
+
+ '</div></div></div>';
|
|
985
|
+
}).join('')
|
|
986
|
+
+ '</div>'
|
|
987
|
+
: '<div style="color:#7d8794">还没有黑话表条目</div>';
|
|
988
|
+
box.querySelectorAll('[data-save]').forEach(function(b){ b.onclick = function(){ saveSlang(b.dataset.save, b); }; });
|
|
989
|
+
box.querySelectorAll('[data-del]').forEach(function(b){ b.onclick = function(){ delSlang(b.dataset.del); }; });
|
|
990
|
+
}
|
|
991
|
+
async function addSlang(){
|
|
992
|
+
const term = document.getElementById('slangTerm').value.trim();
|
|
993
|
+
const meaning = document.getElementById('slangMeaning').value.trim();
|
|
994
|
+
if(!term){ toast('词不能空'); return; }
|
|
995
|
+
await api('/api/slang', { method:'POST', body: JSON.stringify({ term: term, meaning: meaning })});
|
|
996
|
+
document.getElementById('slangTerm').value=''; document.getElementById('slangMeaning').value='';
|
|
997
|
+
toast('已添加'); loadSlang();
|
|
998
|
+
}
|
|
999
|
+
async function saveSlang(oldTerm, btn){
|
|
1000
|
+
const card = btn && btn.closest ? btn.closest('.chatcard') : document.querySelector('[data-save="'+CSS.escape(oldTerm)+'"]').closest('.chatcard');
|
|
1001
|
+
const term = card.querySelector('[data-old]').value.trim();
|
|
1002
|
+
const meaning = card.querySelector('[data-mean]').value.trim();
|
|
1003
|
+
await api('/api/slang', { method:'POST', body: JSON.stringify({ term: term, oldTerm: oldTerm, meaning: meaning })});
|
|
1004
|
+
toast('已保存'); loadSlang();
|
|
1005
|
+
}
|
|
1006
|
+
function delSlang(term){
|
|
1007
|
+
askConfirm('删除「'+term+'」?', async function(){
|
|
1008
|
+
await api('/api/slang', { method:'DELETE', body: JSON.stringify({ term: term, oldTerm: term })});
|
|
1009
|
+
loadSlang();
|
|
1010
|
+
});
|
|
1011
|
+
}
|
|
1012
|
+
async function loadBanned(){
|
|
1013
|
+
const allList = await api('/api/banned');
|
|
1014
|
+
const SIZE = 10;
|
|
1015
|
+
const pages = Math.max(1, Math.ceil(allList.length / SIZE));
|
|
1016
|
+
PAGE_STATE.banned = Math.max(0, Math.min(pages - 1, PAGE_STATE.banned));
|
|
1017
|
+
const list = allList.slice(PAGE_STATE.banned * SIZE, PAGE_STATE.banned * SIZE + SIZE);
|
|
1018
|
+
renderPager('bannedPager', 'banned', PAGE_STATE.banned, allList.length, SIZE, loadBanned);
|
|
1019
|
+
const box = document.getElementById('bannedList');
|
|
1020
|
+
box.innerHTML = list.length
|
|
1021
|
+
? '<div style="display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:8px">'
|
|
1022
|
+
+ list.map(function(w){
|
|
1023
|
+
return '<div class="chatcard" style="margin:0"><div class="cbody" style="min-width:0"><div class="row" style="gap:8px;align-items:center">'
|
|
1024
|
+
+ '<input data-edit="'+esc(w)+'" value="'+esc(w)+'" style="flex:1;min-width:0">'
|
|
1025
|
+
+ '<button data-save="'+esc(w)+'">保存</button>'
|
|
1026
|
+
+ '<button class="danger" data-w="'+esc(w)+'">删除</button>'
|
|
1027
|
+
+ '</div></div></div>';
|
|
1028
|
+
}).join('')
|
|
1029
|
+
+ '</div>'
|
|
1030
|
+
: '<div style="color:#7d8794">还没有禁词表条目</div>';
|
|
1031
|
+
box.querySelectorAll('[data-w]').forEach(function(b){ b.onclick = function(){ delBanned(b.dataset.w); }; });
|
|
1032
|
+
box.querySelectorAll('[data-save]').forEach(function(b){
|
|
1033
|
+
b.onclick = function(){
|
|
1034
|
+
const input = box.querySelector('[data-edit="'+CSS.escape(b.dataset.save)+'"]');
|
|
1035
|
+
saveBanned(b.dataset.save, input ? input.value.trim() : '');
|
|
1036
|
+
};
|
|
1037
|
+
});
|
|
1038
|
+
SUM_OPEN.forEach(function(k){
|
|
1039
|
+
const b = document.querySelector('[data-sum="' + String(k).replace(/"/g, '\\"') + '"]');
|
|
1040
|
+
if (b) b.onclick();
|
|
1041
|
+
});
|
|
1042
|
+
}
|
|
1043
|
+
async function saveBanned(oldWord, newWord){
|
|
1044
|
+
if(!newWord){ toast('词不能空'); return; }
|
|
1045
|
+
if(newWord !== oldWord){
|
|
1046
|
+
await api('/api/banned', { method:'DELETE', body: JSON.stringify({ word: oldWord })});
|
|
1047
|
+
await api('/api/banned', { method:'POST', body: JSON.stringify({ word: newWord })});
|
|
1048
|
+
}
|
|
1049
|
+
toast('已保存'); loadBanned();
|
|
1050
|
+
}
|
|
1051
|
+
async function addBanned(){
|
|
1052
|
+
const w = document.getElementById('bannedWord').value.trim();
|
|
1053
|
+
if(!w){ toast('词不能空'); return; }
|
|
1054
|
+
await api('/api/banned', { method:'POST', body: JSON.stringify({ word: w })});
|
|
1055
|
+
document.getElementById('bannedWord').value='';
|
|
1056
|
+
toast('已添加'); loadBanned();
|
|
1057
|
+
}
|
|
1058
|
+
async function delBanned(w){
|
|
1059
|
+
await api('/api/banned', { method:'DELETE', body: JSON.stringify({ word: w })});
|
|
1060
|
+
loadBanned();
|
|
1061
|
+
}
|
|
1062
|
+
function fieldDisplay(f, raw){
|
|
1063
|
+
if ((raw === undefined || raw === null || raw === '') && f.def !== undefined) raw = f.def;
|
|
1064
|
+
if (raw === undefined || raw === null || raw === '') return '';
|
|
1065
|
+
if (Array.isArray(raw)) return raw.join(',');
|
|
1066
|
+
if (typeof raw !== 'number') return String(raw);
|
|
1067
|
+
const v = raw * (f.scale || 1);
|
|
1068
|
+
return String(Math.round(v * 100) / 100);
|
|
1069
|
+
}
|
|
1070
|
+
function renderSettings(s){
|
|
1071
|
+
const box = document.getElementById('settingsFields');
|
|
1072
|
+
const proxyKeys = ['proxyUrl', 'clashApi', 'clashGroup'];
|
|
1073
|
+
const short = SETTINGS_FIELDS.filter(function(f){ return !f.long; });
|
|
1074
|
+
const cfgKeys = ['arkKey', 'biliCookie', 'baiduCookie'];
|
|
1075
|
+
const longs = SETTINGS_FIELDS.filter(function(f){ return f.long && proxyKeys.indexOf(f.key) < 0 && cfgKeys.indexOf(f.key) < 0; });
|
|
1076
|
+
const proxyFields = SETTINGS_FIELDS.filter(function(f){ return proxyKeys.indexOf(f.key) >= 0; });
|
|
1077
|
+
const CARD = '<div style="background:rgba(255,255,255,.035);border:1px solid rgba(255,255,255,.07);border-radius:12px;padding:16px 16px 6px;margin-bottom:16px">';
|
|
1078
|
+
box.innerHTML =
|
|
1079
|
+
CARD
|
|
1080
|
+
+ '<div style="display:flex;justify-content:space-between;align-items:center;margin:0 0 16px">'
|
|
1081
|
+
+ '<span id="winHelpBtn" style="display:inline-flex;align-items:center;gap:8px;cursor:help;font-size:14px;color:#cfd8e3;user-select:none"><span style="display:inline-flex;width:20px;height:20px;border-radius:50%;border:1.5px solid #cfd8e3;align-items:center;justify-content:center;font-size:13px">!</span>使用说明</span>'
|
|
1082
|
+
+ '<span style="color:#f0b04f;font-size:13.5px">请注意次数和条数的区别</span>'
|
|
1083
|
+
+ '</div>'
|
|
1084
|
+
+ '<div style="display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:0 26px">'
|
|
1085
|
+
+ short.map(function(f){
|
|
1086
|
+
const raw = s ? s[f.key] : undefined;
|
|
1087
|
+
const cur = fieldDisplay(f, raw);
|
|
1088
|
+
const unit = f.unit ? '<span style="color:#8a93a0;font-size:12px">'+f.unit+'</span>' : '';
|
|
1089
|
+
if (f.type === 'range') {
|
|
1090
|
+
return '<div><div style="font-size:12.5px;color:#9aa4b2;margin-bottom:4px">'+f.label+'</div>'
|
|
1091
|
+
+ '<div style="display:flex;align-items:center;gap:8px"><input id="set-'+f.key+'" type="range" min="'+(f.min||0)+'" max="'+(f.max||10)+'" step="'+(f.step||1)+'" value="'+cur+'" style="flex:1;min-width:0">'
|
|
1092
|
+
+ '<b id="val-'+f.key+'" style="min-width:26px;text-align:right">'+cur+'</b>'+unit+'</div>'
|
|
1093
|
+
+ '<div style="color:#5f6875;font-size:11px;margin-top:4px">'+(f.hint||'')+'</div></div>';
|
|
1094
|
+
}
|
|
1095
|
+
if (f.type === 'text') {
|
|
1096
|
+
return '<div><div style="font-size:12.5px;color:#9aa4b2;margin-bottom:4px">'+f.label+'</div>'
|
|
1097
|
+
+ '<input id="set-'+f.key+'" type="text" value="'+String(cur).replace(/"/g,'"')+'" style="width:100%;box-sizing:border-box;padding:5px 8px;font-size:12.5px">'
|
|
1098
|
+
+ '<div style="color:#5f6875;font-size:11px;margin-top:4px">'+(f.hint||'')+'</div></div>';
|
|
1099
|
+
}
|
|
1100
|
+
return '<div><div style="font-size:12.5px;color:#9aa4b2;margin-bottom:4px">'+f.label+'</div>'
|
|
1101
|
+
+ '<div style="display:flex;align-items:center;gap:8px"><input id="set-'+f.key+'" type="number" step="'+(f.step||1)+'"'+(f.min!==undefined?' min="'+f.min+'"':'')+(f.max!==undefined?' max="'+f.max+'"':'')+' value="'+cur+'" style="flex:1;min-width:0">'+unit+'</div>'
|
|
1102
|
+
+ '<div style="color:#5f6875;font-size:11px;margin-top:4px">'+(f.hint||'')+'</div></div>';
|
|
1103
|
+
}).join('')
|
|
1104
|
+
+ '</div>'
|
|
1105
|
+
+ '</div>'
|
|
1106
|
+
+ (longs.length ? CARD + '<h4 style="margin:0 0 12px;font-size:13px;color:#9aa4b2">更多设置</h4>' : '')
|
|
1107
|
+
+ longs.map(function(f){
|
|
1108
|
+
const cur = fieldDisplay(f, s ? s[f.key] : undefined);
|
|
1109
|
+
if (f.type === 'textarea') {
|
|
1110
|
+
return '<div style="margin-bottom:20px"><div style="font-size:12.5px;color:#9aa4b2;margin-bottom:6px">'+f.label+'</div><textarea id="set-'+f.key+'" rows="1" style="width:100%;max-width:100%;box-sizing:border-box;min-height:46px;resize:vertical">'+String(cur).replace(/</g,'<')+'</textarea><div style="color:#5f6875;font-size:11px">'+(f.hint||'')+'</div></div>';
|
|
1111
|
+
}
|
|
1112
|
+
return '<div style="margin-bottom:20px"><div style="font-size:12.5px;color:#9aa4b2;margin-bottom:6px">'+f.label+'</div><input id="set-'+f.key+'" type="text" value="'+String(cur).replace(/"/g,'"')+'" style="width:100%"><div style="color:#5f6875;font-size:11px">'+(f.hint||'')+'</div></div>';
|
|
1113
|
+
}).join('')
|
|
1114
|
+
+ (longs.length ? '</div>' : '')
|
|
1115
|
+
;
|
|
1116
|
+
box.querySelectorAll('input[type="range"]').forEach(function(el){
|
|
1117
|
+
el.oninput = function(){
|
|
1118
|
+
const b = document.getElementById('val-' + el.id.slice(4));
|
|
1119
|
+
if (b) b.textContent = el.value;
|
|
1120
|
+
};
|
|
1121
|
+
});
|
|
1122
|
+
const __hb = document.getElementById('winHelpBtn');
|
|
1123
|
+
const __hp = document.getElementById('winHelp');
|
|
1124
|
+
if (__hb && __hp) {
|
|
1125
|
+
const __show = function(){
|
|
1126
|
+
const r = __hb.getBoundingClientRect();
|
|
1127
|
+
__hp.style.display = 'block';
|
|
1128
|
+
const h = __hp.offsetHeight || 420;
|
|
1129
|
+
const w = __hp.offsetWidth || 780;
|
|
1130
|
+
const top = r.top - h - 10;
|
|
1131
|
+
__hp.style.top = (top > 8 ? top : Math.min(window.innerHeight - 80, r.bottom + 10)) + 'px';
|
|
1132
|
+
__hp.style.left = Math.max(8, Math.min(window.innerWidth - w - 8, r.left - 20)) + 'px';
|
|
1133
|
+
};
|
|
1134
|
+
const __hide = function(){ __hp.style.display = 'none'; };
|
|
1135
|
+
__hb.onmouseenter = __show;
|
|
1136
|
+
__hb.onmouseleave = __hide;
|
|
1137
|
+
__hb.onclick = __show;
|
|
1138
|
+
}
|
|
1139
|
+
const __rb = document.getElementById('roleHelpBtn');
|
|
1140
|
+
const __rp = document.getElementById('roleHelp');
|
|
1141
|
+
if (__rb && __rp) {
|
|
1142
|
+
const __showR = function(){
|
|
1143
|
+
const r = __rb.getBoundingClientRect();
|
|
1144
|
+
__rp.style.display = 'block';
|
|
1145
|
+
const h = __rp.offsetHeight || 440;
|
|
1146
|
+
const w = __rp.offsetWidth || 720;
|
|
1147
|
+
const top = r.top - h - 10;
|
|
1148
|
+
__rp.style.top = (top > 8 ? top : Math.min(window.innerHeight - 80, r.bottom + 10)) + 'px';
|
|
1149
|
+
__rp.style.left = Math.max(8, Math.min(window.innerWidth - w - 8, r.left - 30)) + 'px';
|
|
1150
|
+
};
|
|
1151
|
+
const __hideR = function(){ __rp.style.display = 'none'; };
|
|
1152
|
+
__rb.onmouseenter = __showR;
|
|
1153
|
+
__rb.onmouseleave = __hideR;
|
|
1154
|
+
__rb.onclick = __showR;
|
|
1155
|
+
}
|
|
1156
|
+
}
|
|
1157
|
+
async function saveSettings(silent){
|
|
1158
|
+
const payload = {};
|
|
1159
|
+
for (const f of SETTINGS_FIELDS) {
|
|
1160
|
+
const el = document.getElementById('set-'+f.key);
|
|
1161
|
+
if (!el) continue;
|
|
1162
|
+
if (f.type === 'range' || f.type === 'number') {
|
|
1163
|
+
const v = Number(el.value);
|
|
1164
|
+
if (!Number.isFinite(v)) continue;
|
|
1165
|
+
let raw = v;
|
|
1166
|
+
if (f.min !== undefined && raw < f.min) raw = f.min;
|
|
1167
|
+
if (f.max !== undefined && raw > f.max) raw = f.max;
|
|
1168
|
+
payload[f.key] = Math.round((raw / (f.scale || 1)) * 10000) / 10000;
|
|
1169
|
+
continue;
|
|
1170
|
+
}
|
|
1171
|
+
if (f.key === 'pokeReplies') {
|
|
1172
|
+
payload[f.key] = el.value.split(/[,,、]/).map(function(v){return v.trim();}).filter(Boolean);
|
|
1173
|
+
continue;
|
|
1174
|
+
}
|
|
1175
|
+
payload[f.key] = el.value.trim();
|
|
1176
|
+
}
|
|
1177
|
+
await api('/api/settings',{method:'POST',body:JSON.stringify(payload)});
|
|
1178
|
+
if(!silent) toast('参数已保存');
|
|
1179
|
+
}
|
|
1180
|
+
async function saveAllSettings(){
|
|
1181
|
+
await saveSettings(true);
|
|
1182
|
+
await saveAdmins();
|
|
1183
|
+
toast('已保存');
|
|
1184
|
+
}
|
|
1185
|
+
function del(name){ askConfirm('删除 '+name+'?', async function(){ await api('/api/sticker?name='+encodeURIComponent(name),{method:'DELETE'}); STICKERS=STICKERS.filter(n=>n!==name); renderGrid(); toast('已删除 '+name); }); }
|
|
1186
|
+
async function upload(){
|
|
1187
|
+
const files=[...document.getElementById('file').files]; if(!files.length)return;
|
|
1188
|
+
for(const f of files){
|
|
1189
|
+
const b64=await new Promise(res=>{const r=new FileReader();r.onload=()=>res(String(r.result).split(',')[1]);r.readAsDataURL(f)});
|
|
1190
|
+
await api('/api/sticker',{method:'POST',body:JSON.stringify({name:f.name,data:b64})});
|
|
1191
|
+
}
|
|
1192
|
+
toast('上传完成'); await refreshStickers();
|
|
1193
|
+
}
|
|
1194
|
+
document.getElementById('pickSticker').onclick = function(){ document.getElementById('file').click(); };
|
|
1195
|
+
document.getElementById('file').onchange = async function(){
|
|
1196
|
+
if (this.files && this.files.length > 0) {
|
|
1197
|
+
await upload();
|
|
1198
|
+
this.value = '';
|
|
1199
|
+
}
|
|
1200
|
+
};
|
|
1201
|
+
function restart(){ askConfirm('重启 DSH 服务?', async function(){ await api('/api/restart',{method:'POST',body:'{}'}); toast('已触发重启'); setTimeout(load,9000); }); }
|
|
1202
|
+
let MEM_PAGE = 0, MEM_DATA = [];
|
|
1203
|
+
const MEM_PAGE_SIZE = 3;
|
|
1204
|
+
function memGroup(rows){
|
|
1205
|
+
const g = {};
|
|
1206
|
+
for (const r of rows) {
|
|
1207
|
+
const k = String(r.from || "");
|
|
1208
|
+
if (!g[k]) g[k] = { from: k, name: "", items: [] };
|
|
1209
|
+
if (!g[k].name && r.name) g[k].name = r.name;
|
|
1210
|
+
g[k].items.push(r);
|
|
1211
|
+
}
|
|
1212
|
+
return Object.keys(g).map(function(k){ return g[k]; }).sort(function(a, b){ return b.items.length - a.items.length; });
|
|
1213
|
+
}
|
|
1214
|
+
const MEM_OPEN_Q = new Set();
|
|
1215
|
+
const MEM_ITEM_PAGE = new Map();
|
|
1216
|
+
function memItemBox(r){
|
|
1217
|
+
const fmt = function(ts){ if(!ts) return ''; const d = new Date(ts); return (d.getMonth()+1)+'/'+d.getDate(); };
|
|
1218
|
+
return '<div style="border:1px solid rgba(255,255,255,.10);border-radius:8px;padding:9px 11px;margin:6px 0;background:rgba(255,255,255,.025)">'
|
|
1219
|
+
+ '<div style="display:flex;justify-content:space-between;gap:10px;align-items:flex-start">'
|
|
1220
|
+
+ '<div style="min-width:0"><div style="font-size:13px;color:#dbe3ee;line-height:1.6">'+esc(r.text)+'</div>'
|
|
1221
|
+
+ '<div style="font-size:11.5px;color:#7d8794;margin-top:4px">权重 '+r.weight+' · '+fmt(r.lastAt||r.at)+' · 用过 '+r.used+' 次 · '+(r.kind==='passive'?'自动摘要':'主动记的')+(r.pinned?' · <span style="color:#f0b04f">已锁定</span>':'')+'</div></div>'
|
|
1222
|
+
+ '<div style="display:flex;flex-direction:column;gap:5px;flex:0 0 auto;align-items:flex-end">'
|
|
1223
|
+
+ (r.pinned ? '<label style="display:inline-flex;align-items:center;gap:5px;font-size:11.5px;color:#f0b04f;cursor:pointer"><input type="checkbox" data-pin="'+r.id+'" checked>锁定</label>' : '<label style="display:inline-flex;align-items:center;gap:5px;font-size:11.5px;color:#9aa4b2;cursor:pointer"><input type="checkbox" data-pin="'+r.id+'">锁定</label>')
|
|
1224
|
+
+ '<button class="danger" data-mem="'+r.id+'" data-act="delete">删除</button></div>'
|
|
1225
|
+
+ '</div></div>';
|
|
1226
|
+
}
|
|
1227
|
+
function memGroupHtml(g){
|
|
1228
|
+
const open = MEM_OPEN_Q.has(g.from);
|
|
1229
|
+
const av = g.from ? 'https://q1.qlogo.cn/g?b=sdk&nk='+g.from+'&s=100' : '';
|
|
1230
|
+
const head = '<div data-memhead="'+esc(g.from)+'" style="display:flex;align-items:center;gap:8px;cursor:pointer;user-select:none;padding:9px 4px;border-bottom:1px solid rgba(255,255,255,.06)">'
|
|
1231
|
+
+ '<span style="width:14px;color:#8a93a0">'+(open?'▾':'▸')+'</span>'
|
|
1232
|
+
+ (av?'<img src="'+av+'" style="width:26px;height:26px;border-radius:50%;object-fit:cover" loading="lazy" onerror="this.style.opacity=.15">':'')
|
|
1233
|
+
+ '<span class="cname">'+esc(g.name||'未知')+'</span><span class="pill">'+esc(g.from||'-')+'</span><span class="pill">'+g.items.filter(function(m){return String(m.text||'')!=='';}).length+' / 18 条</span></div>';
|
|
1234
|
+
const items = g.items.filter(function(m){ return String(m.text || '') !== ''; });
|
|
1235
|
+
const PER = 9;
|
|
1236
|
+
const pages = Math.max(1, Math.ceil(items.length / PER));
|
|
1237
|
+
let page = MEM_ITEM_PAGE.get(g.from) || 0;
|
|
1238
|
+
if (page > pages - 1) { page = pages - 1; MEM_ITEM_PAGE.set(g.from, page); }
|
|
1239
|
+
const slice = items.slice(page * PER, page * PER + PER);
|
|
1240
|
+
const pager = pages > 1
|
|
1241
|
+
? '<div style="display:flex;align-items:center;gap:8px;padding:6px 4px;color:#8a93a0"><button class="ghost" data-mempage="prev" data-from="'+esc(g.from)+'"'+(page<=0?' disabled':'')+'>‹</button><span>'+(page+1)+' / '+pages+'</span><button class="ghost" data-mempage="next" data-from="'+esc(g.from)+'"'+(page>=pages-1?' disabled':'')+'>›</button></div>'
|
|
1242
|
+
: '';
|
|
1243
|
+
const body = open ? (items.length ? '<div style="margin:8px 0 4px">'+slice.map(memItemBox).join('')+'</div>'+pager : '<div style="color:#7d8794;padding:6px 4px">这张表还是空的,最多 45 条</div>') : '';
|
|
1244
|
+
return head + body;
|
|
1245
|
+
}
|
|
1246
|
+
function memPager(page, total){
|
|
1247
|
+
const box = document.getElementById('memKnownPager');
|
|
1248
|
+
if (!box) return;
|
|
1249
|
+
const pages = Math.max(1, Math.ceil(total / MEM_PAGE_SIZE));
|
|
1250
|
+
box.innerHTML = total > 0
|
|
1251
|
+
? '<button class="ghost" data-go="prev"'+(page<=0?' disabled':'')+'>‹</button><span>'+(page+1)+' / '+pages+'</span><button class="ghost" data-go="next"'+(page>=pages-1?' disabled':'')+'>›</button>'
|
|
1252
|
+
: '';
|
|
1253
|
+
box.querySelectorAll('[data-go]').forEach(function(b){
|
|
1254
|
+
b.onclick = function(){
|
|
1255
|
+
const d = b.dataset.go === 'next' ? 1 : -1;
|
|
1256
|
+
MEM_PAGE = Math.max(0, Math.min(pages - 1, MEM_PAGE + d));
|
|
1257
|
+
renderMem();
|
|
1258
|
+
};
|
|
1259
|
+
});
|
|
1260
|
+
}
|
|
1261
|
+
function renderMem(){
|
|
1262
|
+
const view = MEM_DATA.slice(MEM_PAGE * MEM_PAGE_SIZE, MEM_PAGE * MEM_PAGE_SIZE + MEM_PAGE_SIZE);
|
|
1263
|
+
const box = document.getElementById('memKnown');
|
|
1264
|
+
box.innerHTML = view.length ? view.map(memGroupHtml).join('') : '<div style="color:#7d8794;padding:8px 0">还没有记忆</div>';
|
|
1265
|
+
document.getElementById('memKnownCount').textContent = '共 '+MEM_DATA.length+' 人';
|
|
1266
|
+
memPager(MEM_PAGE, MEM_DATA.length);
|
|
1267
|
+
box.querySelectorAll('[data-mem]').forEach(function(b){
|
|
1268
|
+
b.onclick = function(){ memAct(b.dataset.mem, b.dataset.act); };
|
|
1269
|
+
});
|
|
1270
|
+
box.querySelectorAll('[data-pin]').forEach(function(cb){
|
|
1271
|
+
cb.onchange = function(){ memAct(cb.dataset.pin, cb.checked ? 'pin' : 'unpin'); };
|
|
1272
|
+
});
|
|
1273
|
+
box.querySelectorAll('[data-mempage]').forEach(function(b){
|
|
1274
|
+
b.onclick = function(ev){
|
|
1275
|
+
ev.stopPropagation();
|
|
1276
|
+
const from = b.dataset.from;
|
|
1277
|
+
const cur = MEM_ITEM_PAGE.get(from) || 0;
|
|
1278
|
+
MEM_ITEM_PAGE.set(from, Math.max(0, cur + (b.dataset.mempage === 'next' ? 1 : -1)));
|
|
1279
|
+
renderMem();
|
|
1280
|
+
};
|
|
1281
|
+
});
|
|
1282
|
+
box.querySelectorAll('[data-memhead]').forEach(function(h){
|
|
1283
|
+
h.onclick = function(){
|
|
1284
|
+
const q = h.dataset.memhead;
|
|
1285
|
+
if (MEM_OPEN_Q.has(q)) MEM_OPEN_Q.delete(q); else MEM_OPEN_Q.add(q);
|
|
1286
|
+
renderMem();
|
|
1287
|
+
};
|
|
1288
|
+
});
|
|
1289
|
+
}
|
|
1290
|
+
async function loadMemList(){
|
|
1291
|
+
const rows = await api('/api/memory');
|
|
1292
|
+
let contacts = [];
|
|
1293
|
+
try { contacts = await api('/api/contacts'); } catch {}
|
|
1294
|
+
const knownQq = new Set(contacts.map(function(c){ return String(c.qq); }));
|
|
1295
|
+
const knownRows = rows.filter(function(r){ return r.from && knownQq.has(String(r.from)); });
|
|
1296
|
+
MEM_DATA = memGroup(knownRows);
|
|
1297
|
+
MEM_PAGE = Math.min(MEM_PAGE, Math.max(0, Math.ceil(MEM_DATA.length / MEM_PAGE_SIZE) - 1));
|
|
1298
|
+
renderMem();
|
|
1299
|
+
}
|
|
1300
|
+
async function memAct(id, act){
|
|
1301
|
+
if(act === 'delete') { askConfirm('删除这条记忆?', function(){ return memAct(id, '__delete'); }); return; }
|
|
1302
|
+
const real = act === '__delete' ? 'delete' : act;
|
|
1303
|
+
await api('/api/memory', { method:'POST', body: JSON.stringify({ action: real, id: id })});
|
|
1304
|
+
if (document.getElementById('memKnown')) loadMemList(); else loadContacts();
|
|
1305
|
+
}
|
|
1306
|
+
|
|
1307
|
+
const PAGE_STATE = { slang: 0, banned: 0, tic: 0, mood: 0 };
|
|
1308
|
+
function renderPager(id, kind, page, total, size, redraw){
|
|
1309
|
+
const box = document.getElementById(id);
|
|
1310
|
+
if (!box) return;
|
|
1311
|
+
const pages = Math.max(1, Math.ceil(total / size));
|
|
1312
|
+
box.innerHTML = '<button class="ghost" data-pg="prev"'+(page<=0?' disabled':'')+'>‹</button><span>'+(page+1)+' / '+pages+'</span><button class="ghost" data-pg="next"'+(page>=pages-1?' disabled':'')+'>›</button>';
|
|
1313
|
+
box.querySelectorAll('[data-pg]').forEach(function(b){
|
|
1314
|
+
b.onclick = function(){
|
|
1315
|
+
PAGE_STATE[kind] = Math.max(0, Math.min(pages - 1, page + (b.dataset.pg === 'next' ? 1 : -1)));
|
|
1316
|
+
redraw();
|
|
1317
|
+
};
|
|
1318
|
+
});
|
|
1319
|
+
}
|
|
1320
|
+
function esc(s){return String(s==null?'':s).replace(/[&<>]/g,function(m){return {'&':'&','<':'<','>':'>'}[m]})}
|
|
1321
|
+
let CHAT_G = 0, CHAT_P = 0, CHAT_DATA = { groups: [], privs: [] };
|
|
1322
|
+
const SUM_OPEN = new Set();
|
|
1323
|
+
const CHAT_PAGE_SIZE = 3;
|
|
1324
|
+
function chatCard(c){
|
|
1325
|
+
const st = c.busy ? '<span class="pill busy">回复中</span>' : (Date.now() - c.lastMs < 300000 ? '<span class="pill on">活跃</span>' : '<span class="pill">闲置</span>');
|
|
1326
|
+
const dot = c.unread ? '<span class="gdot"></span>' : '';
|
|
1327
|
+
const t = c.lastText ? '<div class="msg">最后:'+esc(c.lastText)+'</div>' : '';
|
|
1328
|
+
const r = c.replyText ? '<div class="msg reply">回复:'+esc(c.replyText)+'</div>' : '';
|
|
1329
|
+
const label = c.kind === 'group'
|
|
1330
|
+
? (c.name ? esc(c.name)+' ('+esc(c.target)+')' : '群 '+esc(c.target))
|
|
1331
|
+
: (c.name ? esc(c.name)+'('+esc(c.target)+')' : esc(c.target));
|
|
1332
|
+
const pct = c.turnPct || 0;
|
|
1333
|
+
const bar = '<div style="margin-top:6px"><div style="height:6px;border-radius:4px;background:#22262c;overflow:hidden"><div style="height:100%;width:'+pct+'%;background:'+(pct>=80?'#e0524a':pct>=50?'#d8a13a':'#4a9e5a')+'"></div></div><div class="cmeta">聊天次数 '+c.turns+'/'+(c.turnsLimit||30)+' 次 · '+Math.round((c.chars||0)/1000)+'k/'+Math.round((c.charLimit||400000)/1000)+'k 字符</div></div>';
|
|
1334
|
+
return '<div class="chatcard'+(c.busy?' busy':'')+'"><div class="avwrap"><img class="av" src="'+c.avatar+'" loading="lazy" onerror="this.style.opacity=.15"></div><div class="cbody"><div class="chead">'+dot+'<span class="cname">'+label+'</span>'+st+'</div>'+t+r+'<div class="cmeta">最后活跃 '+esc(c.last)+'</div>'+bar
|
|
1335
|
+
+ '<div style="margin-top:6px"><button class="ghost" data-sum="'+esc(c.key)+'" style="font-size:11.5px;padding:2px 9px">摘要 ▸</button></div>'
|
|
1336
|
+
+ '<div data-sumbody="'+esc(c.key)+'" style="display:none;margin-top:8px;grid-template-columns:repeat(3,minmax(0,1fr));gap:6px"></div>'
|
|
1337
|
+
+ '</div></div>';
|
|
1338
|
+
}
|
|
1339
|
+
function chatPager(id, page, total){
|
|
1340
|
+
const box = document.getElementById(id);
|
|
1341
|
+
if (!box) return;
|
|
1342
|
+
const pages = Math.max(1, Math.ceil(total / CHAT_PAGE_SIZE));
|
|
1343
|
+
box.innerHTML = total > 0
|
|
1344
|
+
? '<button class="ghost" data-go="prev"'+(page<=0?' disabled':'')+'>‹</button><span>'+(page+1)+' / '+pages+'</span><button class="ghost" data-go="next"'+(page>=pages-1?' disabled':'')+'>›</button>'
|
|
1345
|
+
: '';
|
|
1346
|
+
box.querySelectorAll('[data-go]').forEach(function(b){
|
|
1347
|
+
b.onclick = function(){
|
|
1348
|
+
const d = b.dataset.go === 'next' ? 1 : -1;
|
|
1349
|
+
if (id === 'chatGPager') { CHAT_G = Math.max(0, Math.min(pages - 1, CHAT_G + d)); } else { CHAT_P = Math.max(0, Math.min(pages - 1, CHAT_P + d)); }
|
|
1350
|
+
drawChats();
|
|
1351
|
+
};
|
|
1352
|
+
});
|
|
1353
|
+
}
|
|
1354
|
+
async function openSummary(key, label){
|
|
1355
|
+
const overlay = document.getElementById('sumOverlay');
|
|
1356
|
+
const grid = document.getElementById('sumGrid');
|
|
1357
|
+
const title = document.getElementById('sumTitle');
|
|
1358
|
+
if (!overlay || !grid) return;
|
|
1359
|
+
let rows = [];
|
|
1360
|
+
try { rows = await api('/api/summaries?key=' + encodeURIComponent(key)); } catch {}
|
|
1361
|
+
const cells = [];
|
|
1362
|
+
for (let i = 0; i < 9; i++) {
|
|
1363
|
+
const r = rows[i];
|
|
1364
|
+
if (r) {
|
|
1365
|
+
const d = new Date(r.at || Date.now());
|
|
1366
|
+
const ds = (d.getMonth() + 1) + '/' + d.getDate() + ' ' + String(d.getHours()).padStart(2, '0') + ':' + String(d.getMinutes()).padStart(2, '0');
|
|
1367
|
+
cells.push('<div style="position:relative;border:2px solid #4a5057;background:#0c0e10;padding:7px 8px;height:96px;overflow:auto;font-size:11.5px;line-height:1.55"><div style="color:#8a93a0;margin-bottom:3px">' + esc(ds) + '<span data-sumdel="' + i + '" style="float:right;cursor:pointer;color:#e05555;padding:0 3px">×</span></div>' + esc(String(r.text || '').slice(0, 240)) + '</div>');
|
|
1368
|
+
} else {
|
|
1369
|
+
cells.push('<div style="border:2px dashed #3a3f45;height:96px;display:flex;align-items:center;justify-content:center;color:#4a5057;font-size:11.5px">空</div>');
|
|
1370
|
+
}
|
|
1371
|
+
}
|
|
1372
|
+
grid.innerHTML = cells.join('');
|
|
1373
|
+
grid.querySelectorAll('[data-sumdel]').forEach(function(el){
|
|
1374
|
+
el.onclick = function(ev){
|
|
1375
|
+
ev.stopPropagation();
|
|
1376
|
+
const idx = Number(el.dataset.sumdel);
|
|
1377
|
+
askConfirm('删除这条摘要?', async function(){
|
|
1378
|
+
await api('/api/summaries', { method:'POST', body: JSON.stringify({ action:'delete', key: key, index: idx })});
|
|
1379
|
+
openSummary(key, label);
|
|
1380
|
+
});
|
|
1381
|
+
};
|
|
1382
|
+
});
|
|
1383
|
+
if (title) title.textContent = '会话摘要' + (label ? ':' + label : '');
|
|
1384
|
+
overlay.style.display = 'flex';
|
|
1385
|
+
if (!overlay.__bound) {
|
|
1386
|
+
overlay.__bound = true;
|
|
1387
|
+
overlay.onclick = function(e){ if (e.target === overlay) overlay.style.display = 'none'; };
|
|
1388
|
+
const c = document.getElementById('sumClose');
|
|
1389
|
+
if (c) c.onclick = function(){ overlay.style.display = 'none'; };
|
|
1390
|
+
}
|
|
1391
|
+
}
|
|
1392
|
+
function toggleSummary(key, label){
|
|
1393
|
+
openSummary(key, label);
|
|
1394
|
+
}
|
|
1395
|
+
function drawChats(){
|
|
1396
|
+
const g = CHAT_DATA.groups, p = CHAT_DATA.privs;
|
|
1397
|
+
const gStart = CHAT_G * CHAT_PAGE_SIZE, pStart = CHAT_P * CHAT_PAGE_SIZE;
|
|
1398
|
+
const gView = g.slice(gStart, gStart + CHAT_PAGE_SIZE);
|
|
1399
|
+
const pView = p.slice(pStart, pStart + CHAT_PAGE_SIZE);
|
|
1400
|
+
const gBox = document.getElementById('chatGroups');
|
|
1401
|
+
const pBox = document.getElementById('chatPrivs');
|
|
1402
|
+
gBox.innerHTML = (g.length ? gView.map(chatCard).join('') : '<div style="color:#7d8794;padding:10px 0">还没有群聊会话</div>') + '<div style="height:'+((CHAT_PAGE_SIZE - gView.length) * 140)+'px"></div>';
|
|
1403
|
+
pBox.innerHTML = (p.length ? pView.map(chatCard).join('') : '<div style="color:#7d8794;padding:10px 0">还没有私聊会话</div>') + '<div style="height:'+((CHAT_PAGE_SIZE - pView.length) * 140)+'px"></div>';
|
|
1404
|
+
document.getElementById('chatGCount').textContent = '共 '+g.length+' 个';
|
|
1405
|
+
document.getElementById('chatPCount').textContent = '共 '+p.length+' 个';
|
|
1406
|
+
chatPager('chatGPager', CHAT_G, g.length);
|
|
1407
|
+
chatPager('chatPPager', CHAT_P, p.length);
|
|
1408
|
+
document.querySelectorAll('[data-sum]').forEach(function(btn){
|
|
1409
|
+
btn.onclick = function(){
|
|
1410
|
+
const card = btn.closest('.chatcard');
|
|
1411
|
+
const nameEl = card ? card.querySelector('.cname') : null;
|
|
1412
|
+
toggleSummary(btn.dataset.sum, nameEl ? nameEl.textContent.trim() : '');
|
|
1413
|
+
};
|
|
1414
|
+
});
|
|
1415
|
+
}
|
|
1416
|
+
async function loadChats(){
|
|
1417
|
+
const list = await api('/api/chats');
|
|
1418
|
+
let contacts = [];
|
|
1419
|
+
try { contacts = await api('/api/contacts'); } catch {}
|
|
1420
|
+
const names = {};
|
|
1421
|
+
for (const c of contacts) names[String(c.qq)] = String(c.name || c.nick || "");
|
|
1422
|
+
const all = list.map(function(c){
|
|
1423
|
+
c.name = c.kind === 'group' ? (c.name || '') : (names[String(c.target)] || '');
|
|
1424
|
+
return c;
|
|
1425
|
+
}).sort(function(a, b){ return (b.lastMs || 0) - (a.lastMs || 0); });
|
|
1426
|
+
CHAT_DATA.groups = all.filter(function(c){ return c.kind === 'group'; });
|
|
1427
|
+
CHAT_DATA.privs = all.filter(function(c){ return c.kind !== 'group'; });
|
|
1428
|
+
CHAT_G = Math.min(CHAT_G, Math.max(0, Math.ceil(CHAT_DATA.groups.length / CHAT_PAGE_SIZE) - 1));
|
|
1429
|
+
CHAT_P = Math.min(CHAT_P, Math.max(0, Math.ceil(CHAT_DATA.privs.length / CHAT_PAGE_SIZE) - 1));
|
|
1430
|
+
drawChats();
|
|
1431
|
+
}
|
|
1432
|
+
let MEME_PAGE = 0;
|
|
1433
|
+
const MEME_OPEN = new Set();
|
|
1434
|
+
function memeCard(m){
|
|
1435
|
+
const t = String(m.title || "");
|
|
1436
|
+
return '<div class="chatcard" data-meme="'+esc(t)+'" style="margin:0 0 8px"><div class="cbody" style="min-width:0">'
|
|
1437
|
+
+ '<div style="display:flex;align-items:center;gap:8px">'
|
|
1438
|
+
+ '<div data-msec="'+esc(t)+'" style="display:flex;align-items:center;gap:8px;cursor:pointer;user-select:none;font-size:13px;color:#cfd8e3;flex:1;min-width:0">'
|
|
1439
|
+
+ '<span data-marrow style="width:12px;color:#8a93a0">▸</span>'
|
|
1440
|
+
+ '<b style="font-size:13.5px">'+esc(t)+'</b>'
|
|
1441
|
+
+ (m.tag ? '<span class="pill">'+esc(m.tag)+'</span>' : '')
|
|
1442
|
+
+ '</div>'
|
|
1443
|
+
+ '<button class="danger" data-mdel="'+esc(t)+'" title="删除这个词条" style="font-size:15px;line-height:1;padding:2px 8px;border-radius:6px">×</button>'
|
|
1444
|
+
+ '</div>'
|
|
1445
|
+
+ '<div data-mbody style="display:none;margin-top:8px">'
|
|
1446
|
+
+ '<div class="row" style="gap:6px;align-items:center;margin-bottom:6px">'
|
|
1447
|
+
+ '<input data-mt="'+esc(t)+'" value="'+esc(t)+'" placeholder="标题" style="flex:1;min-width:0">'
|
|
1448
|
+
+ '<input data-mg="'+esc(t)+'" value="'+esc(m.tag||"")+'" placeholder="标签" style="width:120px">'
|
|
1449
|
+
+ '</div>'
|
|
1450
|
+
+ '<textarea data-mc="'+esc(t)+'" rows="3" placeholder="内容:梗是什么、出处、什么场合接,随便写" style="width:100%;box-sizing:border-box">'+esc(m.content||"")+'</textarea>'
|
|
1451
|
+
+ '<div class="row" style="gap:6px;margin-top:6px;justify-content:flex-end">'
|
|
1452
|
+
+ '<button data-msave="'+esc(t)+'">保存</button>'
|
|
1453
|
+
+ '</div></div></div></div>';
|
|
1454
|
+
}
|
|
1455
|
+
async function loadMemes(page){
|
|
1456
|
+
if (typeof page === 'number') MEME_PAGE = page;
|
|
1457
|
+
const q = (document.getElementById('memeSearch') || {}).value || '';
|
|
1458
|
+
const r = await api('/api/memes?q=' + encodeURIComponent(q));
|
|
1459
|
+
const items = r.items || [];
|
|
1460
|
+
const cnt = document.getElementById('memeCount');
|
|
1461
|
+
if (cnt) cnt.textContent = '共 ' + (r.total !== void 0 ? r.total : items.length) + ' 条';
|
|
1462
|
+
const SIZE = 8;
|
|
1463
|
+
const pages = Math.max(1, Math.ceil(items.length / SIZE));
|
|
1464
|
+
MEME_PAGE = Math.max(0, Math.min(pages - 1, MEME_PAGE));
|
|
1465
|
+
const view = items.slice(MEME_PAGE * SIZE, MEME_PAGE * SIZE + SIZE);
|
|
1466
|
+
const box = document.getElementById('memeList');
|
|
1467
|
+
box.innerHTML = view.length ? view.map(memeCard).join('') : '<div style="color:#7d8794">还没有词条</div>';
|
|
1468
|
+
box.querySelectorAll('.chatcard[data-meme]').forEach(function(card){
|
|
1469
|
+
const title = card.dataset.meme;
|
|
1470
|
+
const body = card.querySelector('[data-mbody]');
|
|
1471
|
+
const arrow = card.querySelector('[data-marrow]');
|
|
1472
|
+
const apply = function(open){ body.style.display = open ? 'block' : 'none'; if (arrow) arrow.textContent = open ? '▾' : '▸'; };
|
|
1473
|
+
apply(MEME_OPEN.has(title));
|
|
1474
|
+
card.querySelector('[data-msec]').onclick = function(){
|
|
1475
|
+
const open = !MEME_OPEN.has(title);
|
|
1476
|
+
if (open) MEME_OPEN.add(title); else MEME_OPEN.delete(title);
|
|
1477
|
+
apply(open);
|
|
1478
|
+
};
|
|
1479
|
+
});
|
|
1480
|
+
const pager = document.getElementById('memePager');
|
|
1481
|
+
if (pager) {
|
|
1482
|
+
pager.innerHTML = '<button class="ghost" data-mgo="prev"' + (MEME_PAGE <= 0 ? ' disabled' : '') + '>‹</button><span>' + (MEME_PAGE + 1) + ' / ' + pages + '</span><button class="ghost" data-mgo="next"' + (MEME_PAGE >= pages - 1 ? ' disabled' : '') + '>›</button>';
|
|
1483
|
+
pager.querySelectorAll('[data-mgo]').forEach(function(b){
|
|
1484
|
+
b.onclick = function(){ loadMemes(MEME_PAGE + (b.dataset.mgo === 'next' ? 1 : -1)); };
|
|
1485
|
+
});
|
|
1486
|
+
}
|
|
1487
|
+
box.querySelectorAll('[data-msave]').forEach(function(b){
|
|
1488
|
+
b.onclick = async function(){
|
|
1489
|
+
const oldTitle = b.dataset.msave;
|
|
1490
|
+
const card = b.closest('.chatcard');
|
|
1491
|
+
const newTitle = (card.querySelector('[data-mt]').value || '').trim() || oldTitle;
|
|
1492
|
+
const content = card.querySelector('[data-mc]').value;
|
|
1493
|
+
const tag = card.querySelector('[data-mg]').value;
|
|
1494
|
+
await api('/api/memes', { method: 'POST', body: JSON.stringify({ title: newTitle, content: content, tag: tag }) });
|
|
1495
|
+
if (newTitle !== oldTitle) await api('/api/memes', { method: 'POST', body: JSON.stringify({ deleteTitle: oldTitle }) });
|
|
1496
|
+
toast('已保存'); loadMemes();
|
|
1497
|
+
};
|
|
1498
|
+
});
|
|
1499
|
+
box.onclick = async function(ev){
|
|
1500
|
+
const x = ev.target && ev.target.closest ? ev.target.closest('[data-mdel]') : null;
|
|
1501
|
+
if (!x) return;
|
|
1502
|
+
ev.stopPropagation();
|
|
1503
|
+
if (x.dataset.arm !== '1') {
|
|
1504
|
+
x.dataset.arm = '1';
|
|
1505
|
+
x.textContent = '确认?';
|
|
1506
|
+
x.style.fontSize = '11.5px';
|
|
1507
|
+
clearTimeout(x.__armTimer);
|
|
1508
|
+
x.__armTimer = setTimeout(function(){ x.dataset.arm = '0'; x.textContent = '×'; x.style.fontSize = '15px'; }, 3000);
|
|
1509
|
+
return;
|
|
1510
|
+
}
|
|
1511
|
+
clearTimeout(x.__armTimer);
|
|
1512
|
+
try {
|
|
1513
|
+
await api('/api/memes', { method: 'POST', body: JSON.stringify({ deleteTitle: x.dataset.mdel }) });
|
|
1514
|
+
toast('已删除'); loadMemes();
|
|
1515
|
+
} catch (e) { toast('删除失败'); }
|
|
1516
|
+
};
|
|
1517
|
+
}
|
|
1518
|
+
async function addMeme(){
|
|
1519
|
+
const title = document.getElementById('memeNewTitle').value.trim();
|
|
1520
|
+
const tag = document.getElementById('memeNewTag').value.trim();
|
|
1521
|
+
if (!title) { toast('先写标题'); return; }
|
|
1522
|
+
await api('/api/memes', { method: 'POST', body: JSON.stringify({ title: title, content: '', tag: tag }) });
|
|
1523
|
+
document.getElementById('memeNewTitle').value = '';
|
|
1524
|
+
document.getElementById('memeNewTag').value = '';
|
|
1525
|
+
toast('已添加'); loadMemes();
|
|
1526
|
+
}
|
|
1527
|
+
async function loadLogs(){ const r=await api('/api/logs'); document.getElementById('logs').textContent=r; }
|
|
1528
|
+
async function loadTics(){
|
|
1529
|
+
const groups = await api('/api/tics');
|
|
1530
|
+
const allItems = [];
|
|
1531
|
+
for (const g of groups) {
|
|
1532
|
+
for (const t of (g.list || [])) allItems.push({ key: g.key, scenario: t.scenario || '', phrase: t.phrase || '', count: t.count || 1, kind: t.kind || 'phrase' });
|
|
1533
|
+
}
|
|
1534
|
+
const SIZE = 8;
|
|
1535
|
+
const pages = Math.max(1, Math.ceil(allItems.length / SIZE));
|
|
1536
|
+
PAGE_STATE.tic = Math.max(0, Math.min(pages - 1, PAGE_STATE.tic));
|
|
1537
|
+
const items = allItems.slice(PAGE_STATE.tic * SIZE, PAGE_STATE.tic * SIZE + SIZE);
|
|
1538
|
+
renderPager('ticPager', 'tic', PAGE_STATE.tic, allItems.length, SIZE, loadTics);
|
|
1539
|
+
const box = document.getElementById('ticList');
|
|
1540
|
+
box.innerHTML = items.length
|
|
1541
|
+
? '<div style="display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:8px">'
|
|
1542
|
+
+ items.map(function(t){
|
|
1543
|
+
return '<div class="chatcard" style="margin:0"><div class="cbody" style="min-width:0"><div class="row" style="gap:6px;align-items:center;margin-bottom:6px">'
|
|
1544
|
+
+ (t.kind==='pattern'
|
|
1545
|
+
? '<small style="color:#7d8794">句式不需要场景,听到类似的话就套用</small><span style="flex:1"></span>'
|
|
1546
|
+
: '<input data-scen="'+esc(t.phrase)+'" value="'+esc(t.scenario)+'" placeholder="情绪状态(什么心理时用)" style="flex:1;min-width:0">')
|
|
1547
|
+
+ '</div><div class="row" style="gap:6px;align-items:center">'
|
|
1548
|
+
+ '<select data-kind="'+esc(t.phrase)+'" style="width:86px"><option value="phrase"'+(t.kind==='phrase'?' selected':'')+'>口头禅</option><option value="pattern"'+(t.kind==='pattern'?' selected':'')+'>句式</option></select>'
|
|
1549
|
+
+ '<input data-phrase="'+esc(t.phrase)+'" value="'+esc(t.phrase)+'" placeholder="说法" style="flex:1;min-width:0">'
|
|
1550
|
+
+ '<button data-save="'+esc(t.phrase)+'">保存</button>'
|
|
1551
|
+
+ '<button class="danger" data-del="'+esc(t.phrase)+'" data-key="'+esc(t.key)+'">删</button>'
|
|
1552
|
+
+ '</div></div></div>';
|
|
1553
|
+
}).join('')
|
|
1554
|
+
+ '</div>'
|
|
1555
|
+
: '<div style="color:#7d8794">还没有语言模仿记录</div>';
|
|
1556
|
+
|
|
1557
|
+
box.querySelectorAll('[data-save]').forEach(function(b){
|
|
1558
|
+
b.onclick = function(){ saveTic(b.dataset.save); };
|
|
1559
|
+
});
|
|
1560
|
+
box.querySelectorAll('[data-del]').forEach(function(b){
|
|
1561
|
+
b.onclick = function(){ delTic(b.dataset.key, b.dataset.del); };
|
|
1562
|
+
});
|
|
1563
|
+
}
|
|
1564
|
+
async function saveTic(oldPhrase){
|
|
1565
|
+
const box = document.getElementById('ticList');
|
|
1566
|
+
const escSel = (v) => v.replace(/"/g, '\\"');
|
|
1567
|
+
const phraseEl = box.querySelector('[data-phrase="'+escSel(oldPhrase)+'"]');
|
|
1568
|
+
const scenEl = box.querySelector('[data-scen="'+escSel(oldPhrase)+'"]');
|
|
1569
|
+
const kindEl = box.querySelector('[data-kind="'+escSel(oldPhrase)+'"]');
|
|
1570
|
+
const phrase = phraseEl ? phraseEl.value.trim() : oldPhrase;
|
|
1571
|
+
const kindVal = kindEl ? kindEl.value : 'phrase';
|
|
1572
|
+
const scenario = kindVal === 'pattern' ? '' : (scenEl ? scenEl.value.trim() : '');
|
|
1573
|
+
if(!phrase){ toast('说法不能空'); return; }
|
|
1574
|
+
await api('/api/tics', { method:'PUT', body: JSON.stringify({ key:'global', phrase: oldPhrase, newPhrase: phrase, scenario: scenario, kind: kindVal })});
|
|
1575
|
+
toast('已保存'); loadTics();
|
|
1576
|
+
}
|
|
1577
|
+
async function loadMood(){
|
|
1578
|
+
const allRows = await api('/api/mood');
|
|
1579
|
+
const SIZE = 5;
|
|
1580
|
+
const pages = Math.max(1, Math.ceil(allRows.length / SIZE));
|
|
1581
|
+
PAGE_STATE.mood = Math.max(0, Math.min(pages - 1, PAGE_STATE.mood));
|
|
1582
|
+
const rows = allRows.slice(PAGE_STATE.mood * SIZE, PAGE_STATE.mood * SIZE + SIZE);
|
|
1583
|
+
renderPager('moodPager', 'mood', PAGE_STATE.mood, allRows.length, SIZE, loadMood);
|
|
1584
|
+
const tier = function(v){ return v >= 75 ? '卖萌' : v <= 25 ? '暴躁' : '平常'; };
|
|
1585
|
+
document.getElementById('moodList').innerHTML = rows.map(function(r){
|
|
1586
|
+
const av = 'https://q1.qlogo.cn/g?b=sdk&nk='+r.qq+'&s=100';
|
|
1587
|
+
const pct = Math.max(0, Math.min(100, r.value));
|
|
1588
|
+
return '<div class="chatcard"><div class="avwrap"><img class="av" src="'+av+'" loading="lazy" onerror="this.style.opacity=.15"></div><div class="cbody"><div class="chead"><span class="cname">'+esc(r.name||'未知')+'</span><span class="pill">'+esc(r.qq)+'</span><span class="pill">'+tier(r.value)+'</span></div><div class="cmeta">'+r.value+'/100'+(r.reason?(' · 原因:'+esc(r.reason)):'')+'</div><div style="height:6px;border-radius:4px;background:#22262c;overflow:hidden;margin-top:6px"><div style="height:100%;width:'+pct+'%;background:'+(pct>=75?'#4a9e5a':pct<=25?'#e0524a':'#d8a13a')+'"></div></div></div><div style="display:flex;flex-direction:column;gap:6px"><button data-qq="'+r.qq+'" onclick="setMood(this.dataset.qq)">设值</button><button class="danger" data-qq="'+r.qq+'" onclick="clearMood(this.dataset.qq)">清除</button></div></div>';
|
|
1589
|
+
}).join('') || '<div style="color:#7d8794">还没有好感度记录</div>';
|
|
1590
|
+
}
|
|
1591
|
+
async function setMood(qq){
|
|
1592
|
+
const v = prompt('好感度 0-100', '50');
|
|
1593
|
+
if(v === null) return;
|
|
1594
|
+
await api('/api/mood', { method:'POST', body: JSON.stringify({ qq: qq, value: Number(v) || 0 })});
|
|
1595
|
+
loadMood();
|
|
1596
|
+
}
|
|
1597
|
+
function clearMood(qq){
|
|
1598
|
+
askConfirm('清除 '+qq+' 的好感度记录?', async function(){
|
|
1599
|
+
await api('/api/mood', { method:'POST', body: JSON.stringify({ qq: qq, clear: true })});
|
|
1600
|
+
loadMood();
|
|
1601
|
+
});
|
|
1602
|
+
}
|
|
1603
|
+
const CONTACT_OPEN = new Set();
|
|
1604
|
+
const CONTACT_MEM_PAGE = new Map();
|
|
1605
|
+
let CONTACT_PAGE = 0;
|
|
1606
|
+
let MOOD_MAP = {};
|
|
1607
|
+
function contactMemBox(m){
|
|
1608
|
+
return '<div style="border:1px solid rgba(255,255,255,.10);border-radius:8px;padding:7px 9px;margin:5px 0;background:rgba(255,255,255,.025)">'
|
|
1609
|
+
+ '<div style="display:flex;justify-content:space-between;gap:8px;align-items:flex-start">'
|
|
1610
|
+
+ '<div style="min-width:0;font-size:12.5px;color:#cdd6e0;line-height:1.55">'+esc(m.text)
|
|
1611
|
+
+ '<div style="font-size:11px;color:#7d8794;margin-top:3px">记住于 '+((function(ts){ if(!ts) return '?'; const d=new Date(ts); return (d.getMonth()+1)+'/'+d.getDate(); })(m.at||m.lastAt))+' · 用过 '+(m.used||0)+' 次'+(m.pinned?' · 已锁定':'')+'</div></div>'
|
|
1612
|
+
+ '<div style="display:flex;flex-direction:column;gap:5px;align-items:flex-end">'
|
|
1613
|
+
+ (m.pinned ? '<label style="display:inline-flex;align-items:center;gap:5px;font-size:11px;color:#f0b04f;cursor:pointer"><input type="checkbox" data-pin="'+m.id+'" checked>锁定</label>' : '<label style="display:inline-flex;align-items:center;gap:5px;font-size:11px;color:#9aa4b2;cursor:pointer"><input type="checkbox" data-pin="'+m.id+'">锁定</label>')
|
|
1614
|
+
+ '<button class="danger" data-mem="'+m.id+'" data-act="delete" style="font-size:11px;padding:2px 6px">删</button></div></div></div>';
|
|
1615
|
+
}
|
|
1616
|
+
async function loadContacts(){
|
|
1617
|
+
const rows = await api('/api/contacts');
|
|
1618
|
+
let mem = [];
|
|
1619
|
+
try { mem = await api('/api/memory'); } catch {}
|
|
1620
|
+
const byQq = {};
|
|
1621
|
+
for (const m of mem) { const k = String(m.from || ''); (byQq[k] = byQq[k] || []).push(m); }
|
|
1622
|
+
MOOD_MAP = {};
|
|
1623
|
+
try { for (const mr of await api('/api/mood')) MOOD_MAP[String(mr.qq)] = mr; } catch {}
|
|
1624
|
+
const box = document.getElementById('contactList');
|
|
1625
|
+
if (!rows.length) { box.innerHTML = '<div style="color:#7d8794">还没有联系人</div>'; return; }
|
|
1626
|
+
const PAGE = 4;
|
|
1627
|
+
const pages = Math.max(1, Math.ceil(rows.length / PAGE));
|
|
1628
|
+
CONTACT_PAGE = Math.max(0, Math.min(pages - 1, CONTACT_PAGE));
|
|
1629
|
+
const view = rows.slice(CONTACT_PAGE * PAGE, CONTACT_PAGE * PAGE + PAGE);
|
|
1630
|
+
const pager = document.getElementById('contactPager');
|
|
1631
|
+
if (pager) {
|
|
1632
|
+
pager.innerHTML = rows.length > PAGE
|
|
1633
|
+
? '<button class="ghost" data-cgo="prev"'+(CONTACT_PAGE<=0?' disabled':'')+'>‹</button><span>'+(CONTACT_PAGE+1)+' / '+pages+'</span><button class="ghost" data-cgo="next"'+(CONTACT_PAGE>=pages-1?' disabled':'')+'>›</button>'
|
|
1634
|
+
: '<span>共 '+rows.length+' 人</span>';
|
|
1635
|
+
pager.querySelectorAll('[data-cgo]').forEach(function(b){
|
|
1636
|
+
b.onclick = function(){
|
|
1637
|
+
CONTACT_PAGE = Math.max(0, Math.min(pages - 1, CONTACT_PAGE + (b.dataset.cgo === 'next' ? 1 : -1)));
|
|
1638
|
+
loadContacts();
|
|
1639
|
+
};
|
|
1640
|
+
});
|
|
1641
|
+
}
|
|
1642
|
+
box.innerHTML = view.map(function(r){
|
|
1643
|
+
const av = 'https://q1.qlogo.cn/g?b=sdk&nk='+r.qq+'&s=100';
|
|
1644
|
+
const chance = Math.round((r.chance === undefined ? 0.5 : r.chance) * 100);
|
|
1645
|
+
const items = byQq[String(r.qq)] || [];
|
|
1646
|
+
const infoOpen = CONTACT_OPEN.has(r.qq + ':info');
|
|
1647
|
+
const memOpen = CONTACT_OPEN.has(r.qq + ':mem');
|
|
1648
|
+
return '<div class="chatcard" data-qq="'+r.qq+'" style="position:relative">'
|
|
1649
|
+
+ '<div class="avwrap"><img class="av" src="'+av+'" loading="lazy" onerror="this.style.opacity=.15"></div>'
|
|
1650
|
+
+ '<div class="cbody" style="flex:1;min-width:0;display:flex;flex-direction:column;gap:8px">'
|
|
1651
|
+
+ '<div style="display:flex;gap:10px;align-items:stretch">'
|
|
1652
|
+
+ '<div style="flex:1;min-width:0;border:1px solid rgba(255,255,255,.08);border-radius:9px;padding:8px 10px">'
|
|
1653
|
+
+ '<div data-sec="info" style="cursor:pointer;user-select:none;display:flex;gap:6px;align-items:center;font-size:12.5px;color:#cfd8e3"><span style="width:12px;color:#8a93a0">'+(infoOpen?'▾':'▸')+'</span>基本信息</div>'
|
|
1654
|
+
+ '<div style="display:none;margin-top:8px" data-body="info">'
|
|
1655
|
+
+ '<label style="font-size:11.5px;color:#8a93a0">名字<input data-f="name" value="'+esc(r.name||'')+'" placeholder="本名" style="width:100%;margin:3px 0 6px"></label>'
|
|
1656
|
+
+ '<label style="font-size:11.5px;color:#8a93a0">称呼<input data-f="nick" value="'+esc(r.nick||'')+'" placeholder="平时怎么叫" style="width:100%;margin:3px 0 6px"></label>'
|
|
1657
|
+
+ '<label style="font-size:11.5px;color:#8a93a0">简介<textarea data-f="note" rows="3" placeholder="身份、偏好、怎么相处,最多 80 字" style="width:100%;box-sizing:border-box;aspect-ratio:1/1;max-height:150px;min-height:90px;margin:3px 0 6px;resize:vertical">'+esc(r.note||'')+'</textarea></label>'
|
|
1658
|
+
+ '<div style="display:flex;gap:8px;align-items:center;flex-wrap:wrap">'
|
|
1659
|
+
+ '<button data-act="save" style="margin-left:auto">保存</button></div>'
|
|
1660
|
+
+ '</div>'
|
|
1661
|
+
+ '</div>'
|
|
1662
|
+
+ '<div style="flex:1;min-width:0;border:1px solid rgba(255,255,255,.08);border-radius:9px;padding:8px 10px">'
|
|
1663
|
+
+ '<div data-sec="mem" style="cursor:pointer;user-select:none;display:flex;gap:6px;align-items:center;font-size:12.5px;color:#cfd8e3"><span style="width:12px;color:#8a93a0">'+(memOpen?'▾':'▸')+'</span>长期记忆 <span class="pill">'+items.length+' 条</span></div>'
|
|
1664
|
+
+ '<div style="display:none;margin-top:8px" data-body="mem">'
|
|
1665
|
+
+ (function(){
|
|
1666
|
+
const PER = 6;
|
|
1667
|
+
const pages = Math.max(1, Math.ceil(items.length / PER));
|
|
1668
|
+
let pg = CONTACT_MEM_PAGE.get(r.qq) || 0;
|
|
1669
|
+
if (pg > pages - 1) { pg = pages - 1; CONTACT_MEM_PAGE.set(r.qq, pg); }
|
|
1670
|
+
const slice = items.slice(pg * PER, pg * PER + PER);
|
|
1671
|
+
const nav = pages > 1
|
|
1672
|
+
? '<div style="display:flex;align-items:center;gap:8px;margin:6px 0;color:#8a93a0"><button class="ghost" data-mp="prev" data-mq="'+r.qq+'"'+(pg<=0?' disabled':'')+'>‹</button><span>'+(pg+1)+' / '+pages+'</span><button class="ghost" data-mp="next" data-mq="'+r.qq+'"'+(pg>=pages-1?' disabled':'')+'>›</button></div>'
|
|
1673
|
+
: '';
|
|
1674
|
+
return slice.map(contactMemBox).join('') + nav;
|
|
1675
|
+
})()
|
|
1676
|
+
+ '<div style="display:flex;gap:6px;margin-top:6px"><input data-addmem placeholder="加一条记忆" style="flex:1;min-width:0"><button data-act="addmem" style="white-space:nowrap">添加</button></div>'
|
|
1677
|
+
+ '</div>'
|
|
1678
|
+
+ '</div>'
|
|
1679
|
+
+ '</div>'
|
|
1680
|
+
+ '<div style="position:absolute;top:8px;right:8px"><button class="danger" data-act="del" title="删除这个人" style="font-size:15px;line-height:1;padding:2px 8px;border-radius:6px">×</button></div>'
|
|
1681
|
+
+ '</div>'
|
|
1682
|
+
+ (function(){
|
|
1683
|
+
const mv = MOOD_MAP[String(r.qq)];
|
|
1684
|
+
const val = mv === undefined ? null : Number(mv.value);
|
|
1685
|
+
const pct = val === null ? 0 : Math.max(0, Math.min(100, val));
|
|
1686
|
+
const color = pct >= 75 ? '#4a9e5a' : pct <= 25 ? '#e0524a' : '#d8a13a';
|
|
1687
|
+
return '<div style="display:flex;align-items:center;gap:10px;font-size:12px;color:#9aa4b2;margin-top:6px">'
|
|
1688
|
+
+ '<span style="white-space:nowrap">好感度</span>'
|
|
1689
|
+
+ '<b data-moodval style="font-size:17px;line-height:1;min-width:44px;text-align:left;color:'+(val === null ? '#7d8794' : color)+'">'+(val === null ? '—' : val)+'</b>'
|
|
1690
|
+
+ '<input type="range" min="0" max="100" step="1" value="'+(val === null ? 50 : val)+'" data-act="moodslide" style="flex:1;min-width:0">'
|
|
1691
|
+
+ '</div>';
|
|
1692
|
+
})()
|
|
1693
|
+
+ '</div>';
|
|
1694
|
+
}).join('');
|
|
1695
|
+
|
|
1696
|
+
box.querySelectorAll('.chatcard[data-qq]').forEach(function(card){
|
|
1697
|
+
const qq = card.dataset.qq;
|
|
1698
|
+
const infoBody = card.querySelector('[data-body="info"]');
|
|
1699
|
+
const memBody = card.querySelector('[data-body="mem"]');
|
|
1700
|
+
if (CONTACT_OPEN.has(qq + ':info')) infoBody.style.display = 'block';
|
|
1701
|
+
if (CONTACT_OPEN.has(qq + ':mem')) memBody.style.display = 'block';
|
|
1702
|
+
card.querySelector('[data-sec="info"]').onclick = function(){
|
|
1703
|
+
const k = qq + ':info';
|
|
1704
|
+
if (CONTACT_OPEN.has(k)) CONTACT_OPEN.delete(k); else CONTACT_OPEN.add(k);
|
|
1705
|
+
infoBody.style.display = CONTACT_OPEN.has(k) ? 'block' : 'none';
|
|
1706
|
+
const sp = card.querySelector('[data-sec="info"] span'); if (sp) sp.textContent = CONTACT_OPEN.has(k) ? '▾' : '▸';
|
|
1707
|
+
};
|
|
1708
|
+
card.querySelector('[data-sec="mem"]').onclick = function(){
|
|
1709
|
+
const k = qq + ':mem';
|
|
1710
|
+
if (CONTACT_OPEN.has(k)) CONTACT_OPEN.delete(k); else CONTACT_OPEN.add(k);
|
|
1711
|
+
memBody.style.display = CONTACT_OPEN.has(k) ? 'block' : 'none';
|
|
1712
|
+
const sp = card.querySelector('[data-sec="mem"] span'); if (sp) sp.textContent = CONTACT_OPEN.has(k) ? '▾' : '▸';
|
|
1713
|
+
};
|
|
1714
|
+
card.querySelectorAll('[data-mp]').forEach(function(b){
|
|
1715
|
+
b.onclick = function(){
|
|
1716
|
+
const q = b.dataset.mq;
|
|
1717
|
+
const total = (byQq[String(q)] || []).length;
|
|
1718
|
+
const pages = Math.max(1, Math.ceil(total / 6));
|
|
1719
|
+
let pg = CONTACT_MEM_PAGE.get(q) || 0;
|
|
1720
|
+
pg = Math.max(0, Math.min(pages - 1, pg + (b.dataset.mp === 'next' ? 1 : -1)));
|
|
1721
|
+
CONTACT_MEM_PAGE.set(q, pg);
|
|
1722
|
+
loadContacts();
|
|
1723
|
+
};
|
|
1724
|
+
});
|
|
1725
|
+
const get = function(f){ return card.querySelector('[data-f="'+f+'"]'); };
|
|
1726
|
+
const saveBtn = card.querySelector('[data-act="save"]');
|
|
1727
|
+
if (saveBtn) saveBtn.onclick = async function(){
|
|
1728
|
+
await api('/api/contacts', { method:'PUT', body: JSON.stringify({
|
|
1729
|
+
qq: qq,
|
|
1730
|
+
name: get('name').value.trim(),
|
|
1731
|
+
nick: get('nick').value.trim(),
|
|
1732
|
+
note: get('note').value.trim(),
|
|
1733
|
+
})});
|
|
1734
|
+
toast('已保存'); loadContacts();
|
|
1735
|
+
};
|
|
1736
|
+
const addBtn = card.querySelector('[data-act="addmem"]');
|
|
1737
|
+
if (addBtn) addBtn.onclick = async function(){
|
|
1738
|
+
const input = card.querySelector('[data-addmem]');
|
|
1739
|
+
const text = input.value.trim();
|
|
1740
|
+
if (!text) { toast('写点什么'); return; }
|
|
1741
|
+
await api('/api/memory', { method:'POST', body: JSON.stringify({ action:'add', text: text, from: qq })});
|
|
1742
|
+
toast('已添加'); loadContacts();
|
|
1743
|
+
};
|
|
1744
|
+
card.querySelectorAll('[data-mem]').forEach(function(b){
|
|
1745
|
+
b.onclick = function(){ memAct(b.dataset.mem, b.dataset.act); };
|
|
1746
|
+
});
|
|
1747
|
+
card.querySelectorAll('[data-pin]').forEach(function(cb){
|
|
1748
|
+
cb.onchange = function(){ memAct(cb.dataset.pin, cb.checked ? 'pin' : 'unpin'); };
|
|
1749
|
+
});
|
|
1750
|
+
card.querySelector('[data-act="del"]').onclick = function(){ delContact(qq); };
|
|
1751
|
+
const ms = card.querySelector('[data-act="moodslide"]');
|
|
1752
|
+
if (ms) {
|
|
1753
|
+
const lab = card.querySelector('[data-moodval]');
|
|
1754
|
+
const paint = function(v){ if (!lab) return; lab.textContent = v; lab.style.color = v >= 75 ? '#4a9e5a' : v <= 25 ? '#e0524a' : '#d8a13a'; };
|
|
1755
|
+
ms.oninput = function(){ paint(ms.value); };
|
|
1756
|
+
ms.onchange = async function(){
|
|
1757
|
+
await api('/api/mood', { method:'POST', body: JSON.stringify({ qq: qq, value: Number(ms.value) }) });
|
|
1758
|
+
toast('好感度已设为 ' + ms.value);
|
|
1759
|
+
};
|
|
1760
|
+
}
|
|
1761
|
+
|
|
1762
|
+
});
|
|
1763
|
+
}
|
|
1764
|
+
async function saveContact(){
|
|
1765
|
+
const qq = String(document.getElementById('cQQ').value || '').trim();
|
|
1766
|
+
if(!/^\\d{5,12}$/.test(qq)){ toast('QQ 号不对'); return; }
|
|
1767
|
+
const name = document.getElementById('cName').value.trim();
|
|
1768
|
+
const nick = document.getElementById('cNick').value.trim();
|
|
1769
|
+
const note = document.getElementById('cNote').value.trim();
|
|
1770
|
+
await api('/api/contacts', { method:'PUT', body: JSON.stringify({ qq: qq, name: name, nick: nick, note: note })});
|
|
1771
|
+
toast('已保存'); loadContacts();
|
|
1772
|
+
}
|
|
1773
|
+
function delContact(qq){ askConfirm('删除 '+qq+' 的称呼?', async function(){ await api('/api/contacts?qq='+encodeURIComponent(qq),{method:'DELETE'}); loadContacts(); }); }
|
|
1774
|
+
let LIST_STATE = { owners: [], admins: [] };
|
|
1775
|
+
let LIST_LOADED = false;
|
|
1776
|
+
function renderChips(){
|
|
1777
|
+
const draw = function(key, wrapId){
|
|
1778
|
+
const wrap = document.getElementById(wrapId);
|
|
1779
|
+
if(!wrap) return;
|
|
1780
|
+
const list = LIST_STATE[key] || [];
|
|
1781
|
+
wrap.innerHTML = list.length
|
|
1782
|
+
? list.map(function(v){
|
|
1783
|
+
const av = 'https://q1.qlogo.cn/g?b=sdk&nk='+encodeURIComponent(String(v))+'&s=100';
|
|
1784
|
+
return '<span class="chip" style="display:inline-flex;align-items:center;gap:6px">'
|
|
1785
|
+
+ '<img src="'+av+'" loading="lazy" onerror="this.style.opacity=.2" style="width:22px;height:22px;border-radius:50%;object-fit:cover">'
|
|
1786
|
+
+ esc(v)
|
|
1787
|
+
+ ' <button class="danger" data-del-key="'+key+'" data-del-val="'+esc(v)+'" style="padding:0 4px">×</button></span>';
|
|
1788
|
+
}).join('')
|
|
1789
|
+
: '<span style="color:#7d8794">空</span>';
|
|
1790
|
+
wrap.querySelectorAll("[data-del-key]").forEach(function(b){
|
|
1791
|
+
b.onclick = function(){ delChip(b.dataset.delKey, b.dataset.delVal); };
|
|
1792
|
+
});
|
|
1793
|
+
};
|
|
1794
|
+
draw('owners', 'ownersChips');
|
|
1795
|
+
draw('admins', 'adminsChips');
|
|
1796
|
+
}
|
|
1797
|
+
async function loadAdmins(){
|
|
1798
|
+
LIST_LOADED = true;
|
|
1799
|
+
LIST_STATE = await api('/api/lists');
|
|
1800
|
+
renderChips();
|
|
1801
|
+
}
|
|
1802
|
+
function delChip(key, val){
|
|
1803
|
+
LIST_STATE[key] = (LIST_STATE[key] || []).filter(function(v){ return v !== val; });
|
|
1804
|
+
renderChips();
|
|
1805
|
+
try { if (LIST_LOADED) saveAdmins(); } catch (e) {}
|
|
1806
|
+
}
|
|
1807
|
+
function addChip(key){
|
|
1808
|
+
const input = document.getElementById(key === 'owner' ? 'ownerAdd' : 'adminAdd');
|
|
1809
|
+
const v = input.value.trim();
|
|
1810
|
+
if(!/^\\d{5,12}$/.test(v)) { toast('QQ 号不对'); return; }
|
|
1811
|
+
const k = key === 'owner' ? 'owners' : 'admins';
|
|
1812
|
+
if(!(LIST_STATE[k] || []).includes(v)) (LIST_STATE[k] = LIST_STATE[k] || []).push(v);
|
|
1813
|
+
input.value = '';
|
|
1814
|
+
renderChips();
|
|
1815
|
+
try { if (LIST_LOADED) saveAdmins(); } catch (e) {}
|
|
1816
|
+
}
|
|
1817
|
+
async function saveAdmins(){
|
|
1818
|
+
await api('/api/lists', { method: 'POST', body: JSON.stringify({ owners: LIST_STATE.owners, admins: LIST_STATE.admins })});
|
|
1819
|
+
toast('名单已保存');
|
|
1820
|
+
}
|
|
1821
|
+
async function loadReminders(){
|
|
1822
|
+
const list=await api('/api/reminders');
|
|
1823
|
+
const html=list.map(function(r){
|
|
1824
|
+
return '<div class="chatcard"><div class="avwrap"><img class="av" src="'+r.avatar+'" loading="lazy" onerror="this.style.opacity=.15"></div><div class="cbody"><div class="chead"><span class="cname">'+esc(r.kind)+' '+esc(r.target)+'</span><span class="pill">'+esc(r.time)+'</span></div><div class="msg">'+esc(r.text)+'</div><div class="cmeta">发起人 '+esc(r.requester)+'</div></div><button class="danger" data-id="'+r.id+'" onclick="delReminder(this.dataset.id)">删除</button></div>';
|
|
1825
|
+
}).join('');
|
|
1826
|
+
document.getElementById('remindList').innerHTML=html||'<div style="color:#7d8794">没有待办</div>';
|
|
1827
|
+
}
|
|
1828
|
+
function delReminder(id){ askConfirm('删除这条待办?', async function(){ await api('/api/reminders?id='+encodeURIComponent(id),{method:'DELETE'}); loadReminders(); }); }
|
|
1829
|
+
async function addTic(){
|
|
1830
|
+
const scenario = document.getElementById('ticScenario').value.trim();
|
|
1831
|
+
const phrase = document.getElementById('ticPhrase').value.trim();
|
|
1832
|
+
const kind = document.getElementById('ticKind').value;
|
|
1833
|
+
if(!phrase || (kind !== 'pattern' && !scenario)){ toast('口头禅要填情绪状态,句式只填说法'); return; }
|
|
1834
|
+
await api('/api/tics', { method:'POST', body: JSON.stringify({ scenario: scenario, phrase: phrase, kind: kind })});
|
|
1835
|
+
document.getElementById('ticScenario').value=''; document.getElementById('ticPhrase').value='';
|
|
1836
|
+
toast('已添加'); loadTics();
|
|
1837
|
+
}
|
|
1838
|
+
function delTic(key,phrase){ askConfirm('删除「'+phrase+'」?', async function(){ await api('/api/tics',{method:'DELETE',body:JSON.stringify({key:key,phrase:phrase})}); loadTics(); }); }
|
|
1839
|
+
setInterval(function(){var t=document.querySelector('.tab.on');if(t&&t.dataset.id==='chats')loadChats();},5000);
|
|
1840
|
+
load();
|
|
1841
|
+
</script>
|
|
1842
|
+
</html>`;
|
|
1843
|
+
|
|
1844
|
+
function auth(req) {
|
|
1845
|
+
return req.headers["x-admin-token"] === TOKEN || new URL(req.url, "http://x").searchParams.get("t") === TOKEN;
|
|
1846
|
+
}
|
|
1847
|
+
function body(req) {
|
|
1848
|
+
return new Promise((resolve) => {
|
|
1849
|
+
let d = "";
|
|
1850
|
+
req.on("data", (c) => (d += c));
|
|
1851
|
+
req.on("end", () => resolve(d ? JSON.parse(d) : {}));
|
|
1852
|
+
});
|
|
1853
|
+
}
|
|
1854
|
+
function personaFiles() {
|
|
1855
|
+
try {
|
|
1856
|
+
return readdirSync(PDIR).filter((n) => n.endsWith(".md")).sort();
|
|
1857
|
+
} catch {
|
|
1858
|
+
return [];
|
|
1859
|
+
}
|
|
1860
|
+
}
|
|
1861
|
+
function stickerNames() {
|
|
1862
|
+
try {
|
|
1863
|
+
return readdirSync(STICKERS).filter((n) => IMG.test(n));
|
|
1864
|
+
} catch {
|
|
1865
|
+
return [];
|
|
1866
|
+
}
|
|
1867
|
+
}
|
|
1868
|
+
function json(res, obj, code = 200) {
|
|
1869
|
+
res.writeHead(code, { "content-type": "application/json; charset=utf-8" });
|
|
1870
|
+
res.end(JSON.stringify(obj));
|
|
1871
|
+
}
|
|
1872
|
+
|
|
1873
|
+
export async function startConsole(opts = {}) {
|
|
1874
|
+
PROFILE = opts.dataDir || process.env.DSH_PROFILE_DIR || process.cwd();
|
|
1875
|
+
PDIR = join(PROFILE, "persona");
|
|
1876
|
+
SETTINGS = join(PROFILE, "qq-settings.json");
|
|
1877
|
+
STICKERS = join(PROFILE, "stickers");
|
|
1878
|
+
TOKEN = String(opts.token || "change-me");
|
|
1879
|
+
PORT = Number(opts.port || 8090);
|
|
1880
|
+
const HOST = opts.host || "127.0.0.1";
|
|
1881
|
+
|
|
1882
|
+
const server = createServer(async (req, res) => {
|
|
1883
|
+
const url = new URL(req.url, "http://x");
|
|
1884
|
+
try { res.setHeader("cache-control", "no-store, must-revalidate"); res.setHeader("pragma", "no-cache"); } catch {}
|
|
1885
|
+
if (url.pathname === "/open") {
|
|
1886
|
+
res.writeHead(302, { location: "/?t=" + encodeURIComponent(TOKEN) });
|
|
1887
|
+
return res.end();
|
|
1888
|
+
}
|
|
1889
|
+
if (url.pathname === "/") {
|
|
1890
|
+
const __ae = String(req.headers["accept-encoding"] || "");
|
|
1891
|
+
if (__ae.includes("gzip")) {
|
|
1892
|
+
try {
|
|
1893
|
+
const __z = process.getBuiltinModule("node:zlib");
|
|
1894
|
+
const __buf = __z.gzipSync(Buffer.from(page, "utf8"));
|
|
1895
|
+
res.writeHead(200, { "content-type": "text/html; charset=utf-8", "cache-control": "no-store", "content-encoding": "gzip", "vary": "accept-encoding", "content-length": __buf.length });
|
|
1896
|
+
return res.end(__buf);
|
|
1897
|
+
} catch {}
|
|
1898
|
+
}
|
|
1899
|
+
res.writeHead(200, { "content-type": "text/html; charset=utf-8", "cache-control": "no-store" });
|
|
1900
|
+
return res.end(page);
|
|
1901
|
+
}
|
|
1902
|
+
if (url.pathname.startsWith("/fonts/")) {
|
|
1903
|
+
try {
|
|
1904
|
+
const name = basename(url.pathname);
|
|
1905
|
+
if (!/^[A-Za-z0-9._-]+$/.test(name)) { res.writeHead(400).end("bad name"); return; }
|
|
1906
|
+
const buf = readFileSync(join(FONT_DIR, name));
|
|
1907
|
+
const type = name.endsWith(".woff2") ? "font/woff2" : name.endsWith(".txt") ? "text/plain; charset=utf-8" : "application/octet-stream";
|
|
1908
|
+
res.writeHead(200, { "content-type": type, "cache-control": "public, max-age=86400" });
|
|
1909
|
+
return res.end(buf);
|
|
1910
|
+
} catch {
|
|
1911
|
+
res.writeHead(404).end("not found");
|
|
1912
|
+
return;
|
|
1913
|
+
}
|
|
1914
|
+
}
|
|
1915
|
+
const qtParam = url.searchParams.get("t") || "";
|
|
1916
|
+
const proxyPath = url.pathname.startsWith("/napcat/") || url.pathname.startsWith("/webui/") || url.pathname.startsWith("/assets/");
|
|
1917
|
+
if (!auth(req) && !(proxyPath && (qtParam === TOKEN || url.pathname.startsWith("/webui/") || url.pathname.startsWith("/assets/")))) {
|
|
1918
|
+
res.writeHead(401, { "content-type": "text/plain; charset=utf-8" });
|
|
1919
|
+
return res.end("unauthorized");
|
|
1920
|
+
}
|
|
1921
|
+
try {
|
|
1922
|
+
if (url.pathname === "/api/state") {
|
|
1923
|
+
let dsh;
|
|
1924
|
+
if (process.platform === "win32") {
|
|
1925
|
+
const tl = await run("tasklist", ["/FI", "IMAGENAME eq DeepSeek Harness.exe"]);
|
|
1926
|
+
dsh = { stdout: tl.stdout.includes("DeepSeek Harness") ? "active" : "inactive" };
|
|
1927
|
+
} else {
|
|
1928
|
+
dsh = await run("systemctl", ["is-active", "dsh-qqbot"]);
|
|
1929
|
+
}
|
|
1930
|
+
const napUp = await new Promise((resolve) => {
|
|
1931
|
+
const sock = net.connect({ host: "127.0.0.1", port: 3001 });
|
|
1932
|
+
let done = false;
|
|
1933
|
+
const fin = (v) => { if (done) return; done = true; try { sock.destroy(); } catch {} resolve(v); };
|
|
1934
|
+
sock.on("connect", () => fin(true));
|
|
1935
|
+
sock.on("error", () => fin(false));
|
|
1936
|
+
sock.setTimeout(1500, () => fin(false));
|
|
1937
|
+
});
|
|
1938
|
+
const nap = { stdout: napUp ? "yes" : "no" };
|
|
1939
|
+
const settings = existsSync(SETTINGS) ? JSON.parse(noBom(readFileSync(SETTINGS, "utf8"))) : {};
|
|
1940
|
+
return json(res, {
|
|
1941
|
+
files: personaFiles().map((n) => ({ name: n, key: n.replace(/\W/g, ""), label: LABEL[n] || n.replace(/\.md$/, ""), text: noBom(readFileSync(join(PDIR, n), "utf8")) })),
|
|
1942
|
+
botNames: Array.isArray(settings.botNames) ? settings.botNames : [],
|
|
1943
|
+
settings,
|
|
1944
|
+
stickers: stickerNames(),
|
|
1945
|
+
status: { dsh: dsh.stdout.trim(), napcat: nap.stdout.trim() === "yes" }
|
|
1946
|
+
});
|
|
1947
|
+
}
|
|
1948
|
+
if (url.pathname === "/api/botname" && req.method === "POST") {
|
|
1949
|
+
const b = await body(req);
|
|
1950
|
+
const list = Array.isArray(b.names) ? b.names.map((v) => String(v || "").trim()).filter((v) => v !== "") : [];
|
|
1951
|
+
if (list.length === 0) { res.writeHead(400).end("empty"); return; }
|
|
1952
|
+
let st = {};
|
|
1953
|
+
try { st = JSON.parse(noBom(readFileSync(SETTINGS, "utf8"))); } catch {}
|
|
1954
|
+
st.botNames = list.slice(0, 8);
|
|
1955
|
+
writeFileSync(SETTINGS, JSON.stringify(st, null, 1), "utf8");
|
|
1956
|
+
return json(res, { ok: true, botNames: st.botNames });
|
|
1957
|
+
}
|
|
1958
|
+
if (url.pathname === "/api/file" && req.method === "POST") {
|
|
1959
|
+
const b = await body(req);
|
|
1960
|
+
const name = basename(String(b.name || ""));
|
|
1961
|
+
if (!name.endsWith(".md")) throw new Error("bad file");
|
|
1962
|
+
writeFileSync(join(PDIR, name), String(b.text ?? ""), "utf8");
|
|
1963
|
+
return json(res, { ok: true });
|
|
1964
|
+
}
|
|
1965
|
+
if (url.pathname === "/api/slang") {
|
|
1966
|
+
const SF = join(PROFILE, "slang.json");
|
|
1967
|
+
const loadS = () => { try { const v = JSON.parse(noBom(readFileSync(SF, "utf8"))); return v && typeof v === "object" ? v : {}; } catch { return {}; } };
|
|
1968
|
+
if (req.method === "GET") return json(res, loadS());
|
|
1969
|
+
const b = await body(req);
|
|
1970
|
+
const map = loadS();
|
|
1971
|
+
const term = String(b.term || "").trim();
|
|
1972
|
+
const oldTerm = String(b.oldTerm || term).trim();
|
|
1973
|
+
if (req.method === "POST") {
|
|
1974
|
+
if (term === "") throw new Error("bad term");
|
|
1975
|
+
if (oldTerm !== term) delete map[oldTerm];
|
|
1976
|
+
map[term] = String(b.meaning || "").trim().slice(0, 60);
|
|
1977
|
+
const __sk = Object.keys(map);
|
|
1978
|
+
if (__sk.length > 40) for (const k of __sk.slice(0, __sk.length - 40)) delete map[k];
|
|
1979
|
+
writeFileSync(SF, JSON.stringify(map, null, 1), "utf8");
|
|
1980
|
+
return json(res, { ok: true });
|
|
1981
|
+
}
|
|
1982
|
+
if (req.method === "DELETE") {
|
|
1983
|
+
delete map[oldTerm];
|
|
1984
|
+
writeFileSync(SF, JSON.stringify(map, null, 1), "utf8");
|
|
1985
|
+
return json(res, { ok: true });
|
|
1986
|
+
}
|
|
1987
|
+
}
|
|
1988
|
+
if (url.pathname === "/api/banned") {
|
|
1989
|
+
const loadW = () => { try { const st = JSON.parse(noBom(readFileSync(SETTINGS, "utf8"))); return Array.isArray(st.bannedWords) ? st.bannedWords.map((v) => String(v)) : []; } catch { return []; } };
|
|
1990
|
+
const saveW = (list) => { let st = {}; try { st = JSON.parse(noBom(readFileSync(SETTINGS, "utf8"))); } catch {} st.bannedWords = list; writeFileSync(SETTINGS, JSON.stringify(st, null, 2), "utf8"); };
|
|
1991
|
+
if (req.method === "GET") return json(res, loadW());
|
|
1992
|
+
const b = await body(req);
|
|
1993
|
+
const list = loadW();
|
|
1994
|
+
if (req.method === "POST") {
|
|
1995
|
+
const w = String(b.word || "").trim();
|
|
1996
|
+
if (w === "") throw new Error("bad word");
|
|
1997
|
+
if (!list.includes(w)) list.push(w);
|
|
1998
|
+
saveW(list.slice(-40));
|
|
1999
|
+
return json(res, { ok: true });
|
|
2000
|
+
}
|
|
2001
|
+
if (req.method === "DELETE") {
|
|
2002
|
+
const w = String(b.word || "").trim();
|
|
2003
|
+
saveW(list.filter((x) => x !== w));
|
|
2004
|
+
return json(res, { ok: true });
|
|
2005
|
+
}
|
|
2006
|
+
}
|
|
2007
|
+
if (url.pathname === "/api/bili/qr/new" && req.method === "POST") {
|
|
2008
|
+
try {
|
|
2009
|
+
const r = await fetch("https://passport.bilibili.com/x/passport-login/web/qrcode/generate?source=main-fe-header", { headers: { "user-agent": "Mozilla/5.0", referer: "https://www.bilibili.com/" }, signal: AbortSignal.timeout(15000) });
|
|
2010
|
+
const j = await r.json();
|
|
2011
|
+
const d = j && j.data;
|
|
2012
|
+
if (!d || !d.qrcode_key) throw new Error("生成失败");
|
|
2013
|
+
return json(res, { ok: true, key: d.qrcode_key, url: d.url });
|
|
2014
|
+
} catch (e) { return json(res, { ok: false, error: String(e) }); }
|
|
2015
|
+
}
|
|
2016
|
+
if (url.pathname === "/api/bili/avatar") {
|
|
2017
|
+
try {
|
|
2018
|
+
const u = url.searchParams.get("u") || "";
|
|
2019
|
+
if (!/^https?:\/\//i.test(u)) { res.writeHead(400).end(); return; }
|
|
2020
|
+
const r = await fetch(u, { headers: { "user-agent": "Mozilla/5.0", referer: "https://www.bilibili.com/" }, signal: AbortSignal.timeout(10000) });
|
|
2021
|
+
if (!r.ok) { res.writeHead(502).end(); return; }
|
|
2022
|
+
const buf = Buffer.from(await r.arrayBuffer());
|
|
2023
|
+
res.writeHead(200, { "content-type": r.headers.get("content-type") || "image/jpeg", "cache-control": "max-age=3600" });
|
|
2024
|
+
return res.end(buf);
|
|
2025
|
+
} catch (e) { try { res.writeHead(502).end(); } catch {} return; }
|
|
2026
|
+
}
|
|
2027
|
+
if (url.pathname === "/api/bili/memories") {
|
|
2028
|
+
const MF = join(PROFILE, "bili-memory.json");
|
|
2029
|
+
const load = () => { try { const a = JSON.parse(noBom(readFileSync(MF, "utf8"))); return Array.isArray(a) ? a : []; } catch { return []; } };
|
|
2030
|
+
if (req.method === "GET") return json(res, load().slice().reverse());
|
|
2031
|
+
if (req.method === "DELETE") {
|
|
2032
|
+
const b = await body(req);
|
|
2033
|
+
const bv = String(b.bvid || "");
|
|
2034
|
+
writeFileSync(MF, JSON.stringify(load().filter((x) => String(x.bvid || "") !== bv)), "utf8");
|
|
2035
|
+
return json(res, { ok: true });
|
|
2036
|
+
}
|
|
2037
|
+
}
|
|
2038
|
+
if (url.pathname === "/api/self") {
|
|
2039
|
+
try {
|
|
2040
|
+
const f = join(PROFILE, "self.json");
|
|
2041
|
+
const d = JSON.parse(noBom(readFileSync(f, "utf8")));
|
|
2042
|
+
return json(res, { ok: true, id: String(d.id || ""), nick: String(d.nick || "") });
|
|
2043
|
+
} catch { return json(res, { ok: false }); }
|
|
2044
|
+
}
|
|
2045
|
+
if (url.pathname === "/api/bili/me") {
|
|
2046
|
+
try {
|
|
2047
|
+
const st = JSON.parse(noBom(readFileSync(SETTINGS, "utf8").replace(/^\uFEFF/, "")));
|
|
2048
|
+
const cookie = String(st.biliCookie || "").trim();
|
|
2049
|
+
if (cookie === "") return json(res, { ok: true, loggedIn: false });
|
|
2050
|
+
const nr = await fetch("https://api.bilibili.com/x/web-interface/nav", { headers: { "user-agent": "Mozilla/5.0", referer: "https://www.bilibili.com/", cookie }, signal: AbortSignal.timeout(10000) });
|
|
2051
|
+
const nj = await nr.json();
|
|
2052
|
+
const d = nj && nj.data;
|
|
2053
|
+
if (!d || d.isLogin !== true) return json(res, { ok: true, loggedIn: false });
|
|
2054
|
+
return json(res, { ok: true, loggedIn: true, name: d.uname || "", face: d.face || "", level: d.level_info && d.level_info.current_level });
|
|
2055
|
+
} catch (e) { return json(res, { ok: false, error: String(e) }); }
|
|
2056
|
+
}
|
|
2057
|
+
if (url.pathname === "/api/bili/qr/poll") {
|
|
2058
|
+
const qkey = url.searchParams.get("key") || "";
|
|
2059
|
+
try {
|
|
2060
|
+
const r = await fetch("https://passport.bilibili.com/x/passport-login/web/qrcode/poll?qrcode_key=" + encodeURIComponent(qkey) + "&source=main-fe-header", { headers: { "user-agent": "Mozilla/5.0", referer: "https://www.bilibili.com/" }, redirect: "manual", signal: AbortSignal.timeout(15000) });
|
|
2061
|
+
const j = await r.json();
|
|
2062
|
+
const d = j && j.data;
|
|
2063
|
+
const code = d && d.code;
|
|
2064
|
+
if (code === 0) {
|
|
2065
|
+
const sc = typeof r.headers.getSetCookie === "function" ? r.headers.getSetCookie() : [];
|
|
2066
|
+
let cookie = sc.map((v) => String(v).split(";")[0]).filter((v) => v.includes("=")).join("; ");
|
|
2067
|
+
try {
|
|
2068
|
+
const fr = await fetch("https://api.bilibili.com/x/frontend/finger/spi", { headers: { "user-agent": "Mozilla/5.0", referer: "https://www.bilibili.com/" }, signal: AbortSignal.timeout(10000) });
|
|
2069
|
+
const fj = await fr.json();
|
|
2070
|
+
const b3 = fj && fj.data && fj.data.b_3;
|
|
2071
|
+
if (b3 && !cookie.includes("buvid3=")) cookie += (cookie ? "; " : "") + "buvid3=" + b3;
|
|
2072
|
+
} catch {}
|
|
2073
|
+
const st = JSON.parse(noBom(readFileSync(SETTINGS, "utf8").replace(/^\uFEFF/, "")));
|
|
2074
|
+
st.biliCookie = cookie;
|
|
2075
|
+
writeFileSync(SETTINGS, JSON.stringify(st, null, 2), "utf8");
|
|
2076
|
+
let name = "";
|
|
2077
|
+
let face = "";
|
|
2078
|
+
try {
|
|
2079
|
+
const nr = await fetch("https://api.bilibili.com/x/web-interface/nav", { headers: { "user-agent": "Mozilla/5.0", referer: "https://www.bilibili.com/", cookie }, signal: AbortSignal.timeout(10000) });
|
|
2080
|
+
const nj = await nr.json();
|
|
2081
|
+
name = (nj && nj.data && nj.data.uname) || "";
|
|
2082
|
+
face = (nj && nj.data && nj.data.face) || "";
|
|
2083
|
+
} catch {}
|
|
2084
|
+
return json(res, { ok: true, code: 0, name, face });
|
|
2085
|
+
}
|
|
2086
|
+
return json(res, { ok: true, code, message: (d && d.message) || "" });
|
|
2087
|
+
} catch (e) { return json(res, { ok: false, error: String(e) }); }
|
|
2088
|
+
}
|
|
2089
|
+
if (url.pathname === "/api/settings" && req.method === "POST") {
|
|
2090
|
+
const b = await body(req);
|
|
2091
|
+
const cur = existsSync(SETTINGS) ? JSON.parse(noBom(readFileSync(SETTINGS, "utf8"))) : {};
|
|
2092
|
+
const keys = ["stickerChance", "groupAmbientChance", "knownChance", "groupFollowUpSeconds", "messageBufferMs", "replyTwoPartChance", "replyMaxParts", "replyPartChars", "sendGapMs", "replyBudgetPerMin", "proactiveChance", "chatVideoChance", "moodCooldownTurns", "sessionWindowTurns", "sessionResetMinutes", "sessionCharLimit", "proactiveVideoChance", "ttsVoice", "biliCookie", "baiduCookie", "bannedWords", "pokeReplies", "arkKey", "groupAlwaysUsers", "proxyUrl", "clashApi", "clashGroup"];
|
|
2093
|
+
const next = { ...cur };
|
|
2094
|
+
for (const k of keys) if (b[k] !== void 0) next[k] = b[k];
|
|
2095
|
+
writeFileSync(SETTINGS, JSON.stringify(next, null, 2), "utf8");
|
|
2096
|
+
return json(res, { ok: true });
|
|
2097
|
+
}
|
|
2098
|
+
if (url.pathname === "/api/sticker" && req.method === "GET") {
|
|
2099
|
+
const name = basename(url.searchParams.get("name") || "");
|
|
2100
|
+
if (!IMG.test(name)) throw new Error("bad name");
|
|
2101
|
+
if (url.searchParams.get("thumb") === "1") {
|
|
2102
|
+
try {
|
|
2103
|
+
const tdir = join(STICKERS, ".thumbs");
|
|
2104
|
+
const tpath = join(tdir, name + ".jpg");
|
|
2105
|
+
if (!existsSync(tpath)) {
|
|
2106
|
+
mkdirSync(tdir, { recursive: true });
|
|
2107
|
+
const __cp = process.getBuiltinModule("node:child_process");
|
|
2108
|
+
await new Promise((resolve, reject) => {
|
|
2109
|
+
__cp.execFile("ffmpeg", ["-y", "-loglevel", "error", "-i", join(STICKERS, name), "-vf", "scale=180:-1:flags=lanczos", "-frames:v", "1", tpath], (err) => err ? reject(err) : resolve());
|
|
2110
|
+
});
|
|
2111
|
+
}
|
|
2112
|
+
if (existsSync(tpath)) {
|
|
2113
|
+
res.writeHead(200, { "content-type": "image/jpeg", "cache-control": "public, max-age=604800" });
|
|
2114
|
+
return res.end(readFileSync(tpath));
|
|
2115
|
+
}
|
|
2116
|
+
} catch {}
|
|
2117
|
+
}
|
|
2118
|
+
if (url.searchParams.get("anim") === "1") {
|
|
2119
|
+
try {
|
|
2120
|
+
const adir = join(STICKERS, ".thumbs");
|
|
2121
|
+
const apath = join(adir, name + ".v2.webp");
|
|
2122
|
+
if (!existsSync(apath)) {
|
|
2123
|
+
mkdirSync(adir, { recursive: true });
|
|
2124
|
+
const __cp = process.getBuiltinModule("node:child_process");
|
|
2125
|
+
await new Promise((resolve, reject) => {
|
|
2126
|
+
__cp.execFile("ffmpeg", ["-y", "-loglevel", "error", "-i", join(STICKERS, name), "-vf", "fps=8,scale=120:-1:flags=lanczos", "-loop", "0", apath], (err) => err ? reject(err) : resolve());
|
|
2127
|
+
});
|
|
2128
|
+
}
|
|
2129
|
+
if (existsSync(apath)) {
|
|
2130
|
+
res.writeHead(200, { "content-type": "image/webp", "cache-control": "public, max-age=604800" });
|
|
2131
|
+
return res.end(readFileSync(apath));
|
|
2132
|
+
}
|
|
2133
|
+
} catch {}
|
|
2134
|
+
}
|
|
2135
|
+
const data = readFileSync(join(STICKERS, name));
|
|
2136
|
+
const ext = name.split(".").pop().toLowerCase();
|
|
2137
|
+
res.writeHead(200, { "content-type": "image/" + (ext === "jpg" ? "jpeg" : ext), "cache-control": "public, max-age=604800" });
|
|
2138
|
+
return res.end(data);
|
|
2139
|
+
}
|
|
2140
|
+
if (url.pathname === "/api/sticker" && req.method === "POST") {
|
|
2141
|
+
const b = await body(req);
|
|
2142
|
+
const name = basename(String(b.name || "upload.png"));
|
|
2143
|
+
if (!IMG.test(name)) throw new Error("bad type");
|
|
2144
|
+
writeFileSync(join(STICKERS, name), Buffer.from(String(b.data || ""), "base64"));
|
|
2145
|
+
try {
|
|
2146
|
+
const names = readdirSync(STICKERS).filter((n) => IMG.test(n));
|
|
2147
|
+
if (names.length > 100) {
|
|
2148
|
+
const items = names.map((n) => {
|
|
2149
|
+
let m = 0;
|
|
2150
|
+
try { m = statSync(join(STICKERS, n)).mtimeMs; } catch {}
|
|
2151
|
+
return { n, m };
|
|
2152
|
+
}).sort((a, b) => a.m - b.m);
|
|
2153
|
+
for (const it of items.slice(0, items.length - 100)) {
|
|
2154
|
+
try { unlinkSync(join(STICKERS, it.n)); } catch {}
|
|
2155
|
+
}
|
|
2156
|
+
}
|
|
2157
|
+
} catch {}
|
|
2158
|
+
return json(res, { ok: true });
|
|
2159
|
+
}
|
|
2160
|
+
if (url.pathname === "/api/sticker" && req.method === "DELETE") {
|
|
2161
|
+
const name = basename(url.searchParams.get("name") || "");
|
|
2162
|
+
if (!IMG.test(name)) throw new Error("bad name");
|
|
2163
|
+
unlinkSync(join(STICKERS, name));
|
|
2164
|
+
return json(res, { ok: true });
|
|
2165
|
+
}
|
|
2166
|
+
if (url.pathname === "/api/memory") {
|
|
2167
|
+
const MF = join(PROFILE, "memories.json");
|
|
2168
|
+
const load = () => {
|
|
2169
|
+
try {
|
|
2170
|
+
const arr = JSON.parse(noBom(readFileSync(MF, "utf8")));
|
|
2171
|
+
return Array.isArray(arr) ? arr : [];
|
|
2172
|
+
} catch { return []; }
|
|
2173
|
+
};
|
|
2174
|
+
if (req.method === "GET") {
|
|
2175
|
+
const rows = load().map((m) => ({
|
|
2176
|
+
id: String(m.id || ""),
|
|
2177
|
+
text: String(m.text || ""),
|
|
2178
|
+
from: String(m.from || ""),
|
|
2179
|
+
name: String(m.name || ""),
|
|
2180
|
+
weight: Number(m.weight || 1),
|
|
2181
|
+
at: Number(m.at || 0),
|
|
2182
|
+
lastAt: Number(m.lastAt || 0),
|
|
2183
|
+
used: Number(m.used || 0),
|
|
2184
|
+
pinned: m.pinned === true,
|
|
2185
|
+
kind: String(m.kind || "active")
|
|
2186
|
+
})).sort((a, b) => (b.pinned ? 1 : 0) - (a.pinned ? 1 : 0) || b.weight - a.weight);
|
|
2187
|
+
return json(res, rows);
|
|
2188
|
+
}
|
|
2189
|
+
if (req.method === "POST") {
|
|
2190
|
+
const b = await body(req);
|
|
2191
|
+
const id = String(b.id || "");
|
|
2192
|
+
const act = String(b.action || "");
|
|
2193
|
+
let list = load();
|
|
2194
|
+
if (act === "add") {
|
|
2195
|
+
const text = String(b.text || "").trim().slice(0, 200);
|
|
2196
|
+
if (text === "") throw new Error("bad text");
|
|
2197
|
+
list.push({ id: "m" + Date.now().toString(36) + Math.random().toString(36).slice(2, 6), text, from: String(b.from || ""), name: String(b.name || ""), weight: Number(b.weight || 1), at: Date.now(), lastAt: Date.now(), used: 0, pinned: b.pinned === true });
|
|
2198
|
+
const __from = String(b.from || "");
|
|
2199
|
+
const __mine = list.filter((m) => String(m.from || "") === __from);
|
|
2200
|
+
if (__mine.length > 45) {
|
|
2201
|
+
const __drop = new Set(__mine.filter((m) => m.pinned !== true).slice(0, __mine.length - 45).map((m) => m.id));
|
|
2202
|
+
for (let i = list.length - 1; i >= 0; i--) if (__drop.has(list[i] && list[i].id)) list.splice(i, 1);
|
|
2203
|
+
}
|
|
2204
|
+
} else if (act === "delete") {
|
|
2205
|
+
list = list.filter((m) => String(m.id) !== id);
|
|
2206
|
+
} else if (act === "pin" || act === "unpin") {
|
|
2207
|
+
const m = list.find((x) => String(x.id) === id);
|
|
2208
|
+
if (m) m.pinned = act === "pin";
|
|
2209
|
+
} else if (act === "weight") {
|
|
2210
|
+
const m = list.find((x) => String(x.id) === id);
|
|
2211
|
+
if (m) m.weight = Math.max(0, Math.min(20, Number(b.weight || 1)));
|
|
2212
|
+
} else {
|
|
2213
|
+
throw new Error("bad action");
|
|
2214
|
+
}
|
|
2215
|
+
writeFileSync(MF, JSON.stringify(list, null, 1), "utf8");
|
|
2216
|
+
return json(res, { ok: true });
|
|
2217
|
+
}
|
|
2218
|
+
}
|
|
2219
|
+
async function groupNamesCached(force) {
|
|
2220
|
+
const f = join(PROFILE, "groupnames.json");
|
|
2221
|
+
try {
|
|
2222
|
+
const d = JSON.parse(noBom(readFileSync(f, "utf8")));
|
|
2223
|
+
if (!force && d && d.__at && Date.now() - Number(d.__at) < 3600000) return d;
|
|
2224
|
+
} catch {}
|
|
2225
|
+
try {
|
|
2226
|
+
const { default: WS } = await import("/root/.dsh/profiles/qqbot/node_modules/ws/index.js");
|
|
2227
|
+
const out = await new Promise((resolve) => {
|
|
2228
|
+
const ws = new WS("ws://127.0.0.1:3001");
|
|
2229
|
+
const done = (v) => { try { ws.close(); } catch {} resolve(v); };
|
|
2230
|
+
const timer = setTimeout(() => done({}), 6000);
|
|
2231
|
+
ws.on("open", () => ws.send(JSON.stringify({ action: "get_group_list", params: {}, echo: "gn" })));
|
|
2232
|
+
ws.on("message", (raw) => {
|
|
2233
|
+
let d;
|
|
2234
|
+
try { d = JSON.parse(String(raw && raw.data ? raw.data : raw)); } catch { return; }
|
|
2235
|
+
if (d.echo !== "gn") return;
|
|
2236
|
+
clearTimeout(timer);
|
|
2237
|
+
const map = { __at: Date.now() };
|
|
2238
|
+
for (const g of (Array.isArray(d.data) ? d.data : [])) {
|
|
2239
|
+
const id = String((g && g.group_id) || "");
|
|
2240
|
+
if (id !== "") map[id] = String((g && g.group_name) || "");
|
|
2241
|
+
}
|
|
2242
|
+
done(map);
|
|
2243
|
+
});
|
|
2244
|
+
ws.on("error", () => { clearTimeout(timer); done({}); });
|
|
2245
|
+
});
|
|
2246
|
+
if (Object.keys(out).length > 1) writeFileSync(f, JSON.stringify(out), "utf8");
|
|
2247
|
+
return out;
|
|
2248
|
+
} catch {
|
|
2249
|
+
return {};
|
|
2250
|
+
}
|
|
2251
|
+
}
|
|
2252
|
+
if (url.pathname === "/api/mood") {
|
|
2253
|
+
const MF = join(PROFILE, "mood.json");
|
|
2254
|
+
const MMF = join(PROFILE, "moodmeta.json");
|
|
2255
|
+
const loadJ = (f) => { try { return JSON.parse(noBom(readFileSync(f, "utf8"))); } catch { return {}; } };
|
|
2256
|
+
if (req.method === "GET") {
|
|
2257
|
+
const moods = loadJ(MF);
|
|
2258
|
+
const meta = loadJ(MMF);
|
|
2259
|
+
const contacts = loadJ(join(PROFILE, "contacts.json"));
|
|
2260
|
+
const rows = Object.keys(moods).map((qq) => {
|
|
2261
|
+
const v = Number(moods[qq]);
|
|
2262
|
+
const m = meta[qq] && typeof meta[qq] === "object" ? meta[qq] : {};
|
|
2263
|
+
const c = contacts[qq];
|
|
2264
|
+
const name = c && typeof c === "object" ? String(c.nick || c.name || "") : String(c || "");
|
|
2265
|
+
return { qq, name, value: Number.isFinite(v) ? v : 50, state: String(m.state || ""), reason: String(m.reason || "") };
|
|
2266
|
+
}).sort((a, b) => a.value - b.value);
|
|
2267
|
+
return json(res, rows);
|
|
2268
|
+
}
|
|
2269
|
+
if (req.method === "POST") {
|
|
2270
|
+
const b = await body(req);
|
|
2271
|
+
const qq = String(b.qq || "").replace(/\D/g, "");
|
|
2272
|
+
if (!/^\d{5,12}$/.test(qq)) throw new Error("bad qq");
|
|
2273
|
+
const moods = loadJ(MF);
|
|
2274
|
+
const meta = loadJ(MMF);
|
|
2275
|
+
if (b.clear === true) {
|
|
2276
|
+
delete moods[qq];
|
|
2277
|
+
delete meta[qq];
|
|
2278
|
+
} else if (typeof b.value === "number") {
|
|
2279
|
+
moods[qq] = Math.max(0, Math.min(100, Math.round(b.value)));
|
|
2280
|
+
meta[qq] = Object.assign({}, meta[qq] || {}, { state: "", tierCd: 99, noticeCd: 99, reason: "" });
|
|
2281
|
+
}
|
|
2282
|
+
writeFileSync(MF, JSON.stringify(moods), "utf8");
|
|
2283
|
+
writeFileSync(MMF, JSON.stringify(meta), "utf8");
|
|
2284
|
+
return json(res, { ok: true });
|
|
2285
|
+
}
|
|
2286
|
+
}
|
|
2287
|
+
if (url.pathname === "/api/summaries" && req.method === "POST") {
|
|
2288
|
+
const sf = join(PROFILE, "summaries.json");
|
|
2289
|
+
const b = await body(req);
|
|
2290
|
+
const k = String(b.key || "");
|
|
2291
|
+
const idx = Number(b.index);
|
|
2292
|
+
let all = {};
|
|
2293
|
+
try { all = JSON.parse(noBom(readFileSync(sf, "utf8"))); } catch { all = {}; }
|
|
2294
|
+
const arr = Array.isArray(all[k]) ? all[k] : [];
|
|
2295
|
+
if (!Number.isInteger(idx) || idx < 0 || idx >= arr.length) { res.writeHead(400).end("bad index"); return; }
|
|
2296
|
+
arr.splice(idx, 1);
|
|
2297
|
+
all[k] = arr;
|
|
2298
|
+
writeFileSync(sf, JSON.stringify(all), "utf8");
|
|
2299
|
+
return json(res, { ok: true });
|
|
2300
|
+
}
|
|
2301
|
+
if (url.pathname === "/api/summaries" && req.method === "GET") {
|
|
2302
|
+
const key = String(url.searchParams.get("key") || "");
|
|
2303
|
+
let all = {};
|
|
2304
|
+
try { all = JSON.parse(noBom(readFileSync(join(PROFILE, "summaries.json"), "utf8"))); } catch {}
|
|
2305
|
+
const arr = Array.isArray(all[key]) ? all[key] : [];
|
|
2306
|
+
return json(res, arr.slice(-9).reverse());
|
|
2307
|
+
}
|
|
2308
|
+
if (url.pathname === "/api/chats") {
|
|
2309
|
+
const SF = join(PROFILE, "state.json");
|
|
2310
|
+
let st = {};
|
|
2311
|
+
try { st = JSON.parse(noBom(readFileSync(SF, "utf8"))); } catch {}
|
|
2312
|
+
const rows = [];
|
|
2313
|
+
for (const k of Object.keys(st)) {
|
|
2314
|
+
const parts = String(k).split(":");
|
|
2315
|
+
const kind = parts[3] === "group" ? "group" : "private";
|
|
2316
|
+
const gid = parts[4] || "";
|
|
2317
|
+
const uid = parts[6] || "";
|
|
2318
|
+
const v = st[k] || {};
|
|
2319
|
+
const target = kind === "group" ? gid : uid;
|
|
2320
|
+
rows.push({
|
|
2321
|
+
key: k,
|
|
2322
|
+
kind,
|
|
2323
|
+
target,
|
|
2324
|
+
avatar: kind === "group"
|
|
2325
|
+
? "https://p.qlogo.cn/gh/" + gid + "/" + gid + "/100"
|
|
2326
|
+
: "https://q1.qlogo.cn/g?b=sdk&nk=" + uid + "&s=100",
|
|
2327
|
+
busy: v.busy === true,
|
|
2328
|
+
unread: v.unread === true,
|
|
2329
|
+
lastMs: Number(v.lastActiveAt || v.replyAt || 0),
|
|
2330
|
+
lastText: String(v.lastText || ""),
|
|
2331
|
+
replyText: String(v.lastReply || ""),
|
|
2332
|
+
turns: Number(v.turns || 0),
|
|
2333
|
+
chars: Number(v.chars || 0),
|
|
2334
|
+
last: (function (ms) {
|
|
2335
|
+
if (!ms) return "-";
|
|
2336
|
+
const diff = Date.now() - ms;
|
|
2337
|
+
if (diff < 60000) return "刚刚";
|
|
2338
|
+
if (diff < 3600000) return Math.floor(diff / 60000) + " 分钟前";
|
|
2339
|
+
if (diff < 86400000) return Math.floor(diff / 3600000) + " 小时前";
|
|
2340
|
+
return Math.floor(diff / 86400000) + " 天前";
|
|
2341
|
+
})(Number(v.lastActiveAt || v.replyAt || 0)),
|
|
2342
|
+
mem: 0,
|
|
2343
|
+
name: ""
|
|
2344
|
+
});
|
|
2345
|
+
}
|
|
2346
|
+
try {
|
|
2347
|
+
let gn = await groupNamesCached(false);
|
|
2348
|
+
const missing = rows.some((r) => r.kind === "group" && !gn[r.target]);
|
|
2349
|
+
if (missing) gn = await groupNamesCached(true);
|
|
2350
|
+
for (const r of rows) {
|
|
2351
|
+
if (r.kind === "group") r.name = String(gn[r.target] || "");
|
|
2352
|
+
}
|
|
2353
|
+
} catch {}
|
|
2354
|
+
try {
|
|
2355
|
+
const mf = join(PROFILE, "memories.json");
|
|
2356
|
+
const mems = JSON.parse(noBom(readFileSync(mf, "utf8")));
|
|
2357
|
+
if (Array.isArray(mems)) {
|
|
2358
|
+
const byUser = {};
|
|
2359
|
+
for (const m of mems) {
|
|
2360
|
+
const from = String((m && m.from) || "");
|
|
2361
|
+
if (from === "") continue;
|
|
2362
|
+
byUser[from] = (byUser[from] || 0) + 1;
|
|
2363
|
+
}
|
|
2364
|
+
for (const r of rows) {
|
|
2365
|
+
if (r.kind === "private") r.mem = byUser[r.target] || 0;
|
|
2366
|
+
}
|
|
2367
|
+
}
|
|
2368
|
+
} catch {}
|
|
2369
|
+
const __keep = rows.filter((r) => !(r.kind === "group" && String(r.key || "").split(":").pop() !== "-"));
|
|
2370
|
+
rows.length = 0;
|
|
2371
|
+
rows.push(...__keep);
|
|
2372
|
+
rows.sort((a, b) => b.lastMs - a.lastMs);
|
|
2373
|
+
const merged = [];
|
|
2374
|
+
const seen = new Map();
|
|
2375
|
+
for (const r of rows) {
|
|
2376
|
+
const id = r.kind + ":" + r.target;
|
|
2377
|
+
const prev = seen.get(id);
|
|
2378
|
+
if (prev === void 0) {
|
|
2379
|
+
const copy = Object.assign({}, r, { users: 1 });
|
|
2380
|
+
seen.set(id, copy);
|
|
2381
|
+
merged.push(copy);
|
|
2382
|
+
} else {
|
|
2383
|
+
prev.users = (prev.users || 1) + 1;
|
|
2384
|
+
prev.mem = (prev.mem || 0) + (r.mem || 0);
|
|
2385
|
+
if (r.kind === 'group') {
|
|
2386
|
+
prev.turns = (prev.turns || 0) + (r.turns || 0);
|
|
2387
|
+
prev.chars = (prev.chars || 0) + (r.chars || 0);
|
|
2388
|
+
} else {
|
|
2389
|
+
prev.turns = Math.max(prev.turns || 0, r.turns || 0);
|
|
2390
|
+
prev.chars = Math.max(prev.chars || 0, r.chars || 0);
|
|
2391
|
+
}
|
|
2392
|
+
if (r.lastMs > prev.lastMs) {
|
|
2393
|
+
prev.lastMs = r.lastMs;
|
|
2394
|
+
prev.lastText = r.lastText;
|
|
2395
|
+
prev.replyText = r.replyText;
|
|
2396
|
+
prev.last = r.last;
|
|
2397
|
+
prev.busy = r.busy;
|
|
2398
|
+
}
|
|
2399
|
+
}
|
|
2400
|
+
}
|
|
2401
|
+
merged.sort((a, b) => b.lastMs - a.lastMs);
|
|
2402
|
+
let turnsLimit = 30;
|
|
2403
|
+
let charLimit = 400000;
|
|
2404
|
+
try {
|
|
2405
|
+
const st = JSON.parse(noBom(readFileSync(join(PROFILE, "qq-settings.json"), "utf8")));
|
|
2406
|
+
if (typeof st.sessionWindowTurns === "number") turnsLimit = st.sessionWindowTurns;
|
|
2407
|
+
if (typeof st.sessionCharLimit === "number") charLimit = st.sessionCharLimit;
|
|
2408
|
+
} catch {}
|
|
2409
|
+
for (const r of merged) {
|
|
2410
|
+
r.turnsLimit = turnsLimit;
|
|
2411
|
+
r.charLimit = charLimit;
|
|
2412
|
+
r.turnPct = Math.max(0, Math.min(100, Math.round((r.turns / Math.max(1, turnsLimit)) * 100)));
|
|
2413
|
+
}
|
|
2414
|
+
return json(res, merged.slice(0, 60));
|
|
2415
|
+
}
|
|
2416
|
+
if (url.pathname === "/api/memes") {
|
|
2417
|
+
const mfile = join(PROFILE, "memes.json");
|
|
2418
|
+
if (req.method === "GET") {
|
|
2419
|
+
let all = [];
|
|
2420
|
+
try { all = JSON.parse(noBom(readFileSync(mfile, "utf8"))); } catch {}
|
|
2421
|
+
if (!Array.isArray(all)) all = [];
|
|
2422
|
+
const q = String(url.searchParams.get("q") || "").trim().toLowerCase();
|
|
2423
|
+
const items = q === "" ? all : all.filter((m) => (String(m.title || "") + " " + String(m.content || "") + " " + String(m.tag || "")).toLowerCase().includes(q));
|
|
2424
|
+
items.sort((a, b) => String(a.title || "").localeCompare(String(b.title || ""), "zh"));
|
|
2425
|
+
return json(res, { items, total: items.length });
|
|
2426
|
+
}
|
|
2427
|
+
if (req.method === "POST") {
|
|
2428
|
+
let payload = {};
|
|
2429
|
+
try { payload = await body(req); } catch { payload = {}; }
|
|
2430
|
+
let all = [];
|
|
2431
|
+
try { all = JSON.parse(noBom(readFileSync(mfile, "utf8"))); } catch {}
|
|
2432
|
+
if (!Array.isArray(all)) all = [];
|
|
2433
|
+
if (payload.deleteTitle !== void 0) {
|
|
2434
|
+
const t = String(payload.deleteTitle);
|
|
2435
|
+
const next = all.filter((m) => String(m.title) !== t);
|
|
2436
|
+
writeFileSync(mfile, JSON.stringify(next));
|
|
2437
|
+
return json(res, { ok: true, deleted: all.length - next.length });
|
|
2438
|
+
}
|
|
2439
|
+
const title = String(payload.title || "").trim();
|
|
2440
|
+
if (title === "") { res.writeHead(400).end("bad args"); return; }
|
|
2441
|
+
const content = String(payload.content || "");
|
|
2442
|
+
const tag = String(payload.tag || "");
|
|
2443
|
+
const idx = all.findIndex((m) => String(m.title) === title);
|
|
2444
|
+
const at = Date.now();
|
|
2445
|
+
if (idx >= 0) all[idx] = { ...all[idx], title, content, tag, at };
|
|
2446
|
+
else all.push({ title, content, tag, at });
|
|
2447
|
+
if (all.length > 10000) all = all.slice(-10000);
|
|
2448
|
+
writeFileSync(mfile, JSON.stringify(all));
|
|
2449
|
+
return json(res, { ok: true, title });
|
|
2450
|
+
}
|
|
2451
|
+
}
|
|
2452
|
+
if (url.pathname === "/api/tics") {
|
|
2453
|
+
const TF = join(PROFILE, "tics.json");
|
|
2454
|
+
const loadT = () => { try { const d = JSON.parse(noBom(readFileSync(TF, "utf8"))); return d && typeof d === "object" ? d : {}; } catch { return {}; } };
|
|
2455
|
+
if (req.method === "GET") {
|
|
2456
|
+
const all = loadT();
|
|
2457
|
+
const rows = Object.keys(all).map((k) => {
|
|
2458
|
+
const parts = String(k).split(":");
|
|
2459
|
+
const kind = k === "global" ? "" : (parts[4] === "group" ? "群" : parts[4] === "private" ? "私聊" : "");
|
|
2460
|
+
const target = k === "global" ? "" : (parts[5] && parts[5] !== "-" ? parts[5] : (parts[6] || ""));
|
|
2461
|
+
const list = Array.isArray(all[k]) ? all[k] : [];
|
|
2462
|
+
return {
|
|
2463
|
+
key: k,
|
|
2464
|
+
kind,
|
|
2465
|
+
target: String(target),
|
|
2466
|
+
avatar: parts[4] === "group" ? "https://p.qlogo.cn/gh/" + target + "/" + target + "/100" : "https://q1.qlogo.cn/g?b=sdk&nk=" + target + "&s=100",
|
|
2467
|
+
list: list.map((t) => ({ kind: String(t.kind || "phrase"), scenario: String(t.scenario || ""), phrase: String(t.phrase || ""), count: Number(t.count || 1) }))
|
|
2468
|
+
};
|
|
2469
|
+
}).filter((r) => r.list.length > 0);
|
|
2470
|
+
return json(res, rows);
|
|
2471
|
+
}
|
|
2472
|
+
if (req.method === "POST") {
|
|
2473
|
+
const b = await body(req);
|
|
2474
|
+
const key = "global";
|
|
2475
|
+
const all = loadT();
|
|
2476
|
+
const arr = Array.isArray(all[key]) ? all[key] : [];
|
|
2477
|
+
const phrase = String(b.phrase || "").trim().slice(0, 20);
|
|
2478
|
+
const kind = String(b.kind || "") === "pattern" ? "pattern" : "phrase";
|
|
2479
|
+
const scenario = kind === "pattern" ? "" : String(b.scenario || "").trim().slice(0, 40);
|
|
2480
|
+
if (phrase === "") { res.writeHead(400).end("bad args"); return; }
|
|
2481
|
+
if (scenario === "" && kind !== "pattern") { res.writeHead(400).end("bad args"); return; }
|
|
2482
|
+
const hit = arr.find((t) => String(t.phrase) === phrase);
|
|
2483
|
+
if (hit !== void 0) {
|
|
2484
|
+
hit.scenario = scenario;
|
|
2485
|
+
hit.kind = kind;
|
|
2486
|
+
hit.count = Number(hit.count || 1) + 1;
|
|
2487
|
+
hit.at = Date.now();
|
|
2488
|
+
} else {
|
|
2489
|
+
arr.push({ scenario, phrase, count: 1, kind, at: Date.now() });
|
|
2490
|
+
}
|
|
2491
|
+
all[key] = arr.slice(-80);
|
|
2492
|
+
writeFileSync(TF, JSON.stringify(all), "utf8");
|
|
2493
|
+
return json(res, { ok: true });
|
|
2494
|
+
}
|
|
2495
|
+
if (req.method === "PUT") {
|
|
2496
|
+
const b = await body(req);
|
|
2497
|
+
const key = String(b.key || "global");
|
|
2498
|
+
const phrase = String(b.phrase || "");
|
|
2499
|
+
const all = loadT();
|
|
2500
|
+
const arr = Array.isArray(all[key]) ? all[key] : [];
|
|
2501
|
+
const hit = arr.find((t) => String(t.phrase) === phrase);
|
|
2502
|
+
if (hit === void 0) { res.writeHead(404).end("not found"); return; }
|
|
2503
|
+
if (b.kind === "pattern") hit.scenario = "";
|
|
2504
|
+
else if (typeof b.scenario === "string" && b.scenario.trim() !== "") hit.scenario = b.scenario.trim().slice(0, 40);
|
|
2505
|
+
if (typeof b.newPhrase === "string" && b.newPhrase.trim() !== "") hit.phrase = b.newPhrase.trim().slice(0, 20);
|
|
2506
|
+
if (typeof b.count === "number") hit.count = Math.max(1, Math.min(999, Math.round(b.count)));
|
|
2507
|
+
if (b.kind === "pattern" || b.kind === "phrase") hit.kind = b.kind;
|
|
2508
|
+
all[key] = arr;
|
|
2509
|
+
writeFileSync(TF, JSON.stringify(all), "utf8");
|
|
2510
|
+
return json(res, { ok: true });
|
|
2511
|
+
}
|
|
2512
|
+
if (req.method === "DELETE") {
|
|
2513
|
+
const b = await body(req);
|
|
2514
|
+
const key = String(b.key || "global");
|
|
2515
|
+
const phrase = String(b.phrase || "");
|
|
2516
|
+
const all = loadT();
|
|
2517
|
+
if (Array.isArray(all[key])) all[key] = all[key].filter((t) => String(t.phrase) !== phrase);
|
|
2518
|
+
writeFileSync(TF, JSON.stringify(all), "utf8");
|
|
2519
|
+
return json(res, { ok: true });
|
|
2520
|
+
}
|
|
2521
|
+
}
|
|
2522
|
+
if (url.pathname === "/api/reminders") {
|
|
2523
|
+
const REM = join(PROFILE, "reminders.json");
|
|
2524
|
+
let list = [];
|
|
2525
|
+
try {
|
|
2526
|
+
list = JSON.parse(noBom(readFileSync(REM, "utf8")));
|
|
2527
|
+
} catch {}
|
|
2528
|
+
if (req.method === "GET") {
|
|
2529
|
+
const rows = list.map((r) => {
|
|
2530
|
+
const isGroup = r.kind === "group";
|
|
2531
|
+
const target = String(isGroup ? r.groupId : r.userId);
|
|
2532
|
+
const at = Number(r.at);
|
|
2533
|
+
return {
|
|
2534
|
+
id: r.id,
|
|
2535
|
+
kind: isGroup ? "群聊" : "私聊",
|
|
2536
|
+
target,
|
|
2537
|
+
avatar: isGroup ? "https://p.qlogo.cn/gh/" + target + "/" + target + "/100" : "https://q1.qlogo.cn/g?b=sdk&nk=" + target + "&s=100",
|
|
2538
|
+
text: String(r.text || ""),
|
|
2539
|
+
time: new Date(at).toLocaleString("zh-CN", { hour12: false }),
|
|
2540
|
+
requester: String(r.userId || ""),
|
|
2541
|
+
at
|
|
2542
|
+
};
|
|
2543
|
+
}).sort((a, b) => a.at - b.at);
|
|
2544
|
+
return json(res, rows);
|
|
2545
|
+
}
|
|
2546
|
+
if (req.method === "DELETE") {
|
|
2547
|
+
const id = url.searchParams.get("id") || "";
|
|
2548
|
+
const next = list.filter((r) => String(r.id) !== id);
|
|
2549
|
+
writeFileSync(REM, JSON.stringify(next), "utf8");
|
|
2550
|
+
return json(res, { ok: true });
|
|
2551
|
+
}
|
|
2552
|
+
}
|
|
2553
|
+
if (url.pathname === "/api/lists" && req.method === "GET") {
|
|
2554
|
+
const read = (f, k, fb) => {
|
|
2555
|
+
try { const d = JSON.parse(readFileSync(join(PROFILE, f), "utf8")); return Array.isArray(d[k]) ? d[k] : fb; } catch { return fb; }
|
|
2556
|
+
};
|
|
2557
|
+
return json(res, { owners: read("owners.json", "owners", []), admins: read("admins.json", "admins", []) });
|
|
2558
|
+
}
|
|
2559
|
+
if (url.pathname === "/api/lists" && req.method === "POST") {
|
|
2560
|
+
const b = await body(req);
|
|
2561
|
+
const clean = (v) => (Array.isArray(v) ? v : []).map((x) => String(x).trim()).filter((x) => /^\d{5,12}$/.test(x));
|
|
2562
|
+
if (Array.isArray(b.owners)) writeFileSync(join(PROFILE, "owners.json"), JSON.stringify({ owners: clean(b.owners) }, null, 2), "utf8");
|
|
2563
|
+
if (Array.isArray(b.admins)) writeFileSync(join(PROFILE, "admins.json"), JSON.stringify({ admins: clean(b.admins) }, null, 2), "utf8");
|
|
2564
|
+
return json(res, { ok: true });
|
|
2565
|
+
}
|
|
2566
|
+
if (url.pathname === "/api/contacts") {
|
|
2567
|
+
const CF = join(PROFILE, "contacts.json");
|
|
2568
|
+
let map = {};
|
|
2569
|
+
try { map = JSON.parse(readFileSync(CF, "utf8")); } catch {}
|
|
2570
|
+
if (req.method === "GET") {
|
|
2571
|
+
const norm = (v) => (typeof v === "string" ? { name: v, nick: "", note: "", cool: false, chance: 0.5 } : { name: String((v && v.name) || ""), nick: String((v && v.nick) || ""), note: String((v && v.note) || ""), cool: v && v.cool === true, chance: typeof (v && v.chance) === "number" ? Number(v.chance) : 0.5 });
|
|
2572
|
+
const rows = Object.keys(map).map((k) => { const v = norm(map[k]); return { qq: k, name: v.name, nick: v.nick, note: v.note, cool: v.cool, chance: v.chance, avatar: "https://q1.qlogo.cn/g?b=sdk&nk=" + k + "&s=100" }; });
|
|
2573
|
+
return json(res, rows);
|
|
2574
|
+
}
|
|
2575
|
+
if (req.method === "POST") {
|
|
2576
|
+
const b = await body(req);
|
|
2577
|
+
const qq = String(b.qq || "").replace(/\D/g, "").slice(0, 12);
|
|
2578
|
+
const name = String(b.name || "").trim().slice(0, 20);
|
|
2579
|
+
if (!/^\d{5,12}$/.test(qq) || name === "") throw new Error("bad args");
|
|
2580
|
+
map[qq] = { name: name, nick: String(b.nick || "").trim().slice(0, 20), note: String(b.note || "").trim().slice(0, 80), cool: map[qq] && map[qq].cool === true, chance: typeof b.chance === "number" ? Math.max(0, Math.min(1, b.chance)) : (map[qq] && typeof map[qq].chance === "number" ? map[qq].chance : 0.5) };
|
|
2581
|
+
writeFileSync(CF, JSON.stringify(map, null, 2), "utf8");
|
|
2582
|
+
return json(res, { ok: true });
|
|
2583
|
+
}
|
|
2584
|
+
if (req.method === "PUT") {
|
|
2585
|
+
const b = await body(req);
|
|
2586
|
+
const qq = String(b.qq || "").replace(/\D/g, "");
|
|
2587
|
+
if (!/^\d{5,12}$/.test(qq)) throw new Error("bad qq");
|
|
2588
|
+
const cur = map[qq] && typeof map[qq] === "object" ? map[qq] : { name: String((map[qq]) || ""), note: "" };
|
|
2589
|
+
map[qq] = {
|
|
2590
|
+
name: typeof b.name === "string" && b.name.trim() !== "" ? b.name.trim().slice(0, 20) : String(cur.name || ""),
|
|
2591
|
+
nick: typeof b.nick === "string" ? b.nick.trim().slice(0, 20) : String(cur.nick || ""),
|
|
2592
|
+
note: typeof b.note === "string" ? b.note.trim().slice(0, 80) : String(cur.note || ""),
|
|
2593
|
+
cool: typeof b.cool === "boolean" ? b.cool : cur.cool === true,
|
|
2594
|
+
chance: typeof b.chance === "number" ? Math.max(0, Math.min(1, b.chance)) : (typeof cur.chance === "number" ? cur.chance : 0.5)
|
|
2595
|
+
};
|
|
2596
|
+
writeFileSync(CF, JSON.stringify(map, null, 2), "utf8");
|
|
2597
|
+
return json(res, { ok: true, cool: b.cool === true });
|
|
2598
|
+
}
|
|
2599
|
+
if (req.method === "DELETE") {
|
|
2600
|
+
const qq = String(url.searchParams.get("qq") || "");
|
|
2601
|
+
delete map[qq];
|
|
2602
|
+
writeFileSync(CF, JSON.stringify(map, null, 2), "utf8");
|
|
2603
|
+
return json(res, { ok: true });
|
|
2604
|
+
}
|
|
2605
|
+
}
|
|
2606
|
+
if (url.pathname.startsWith("/napcat/") || url.pathname.startsWith("/webui/") || url.pathname.startsWith("/assets/")) {
|
|
2607
|
+
const fwd = new URLSearchParams(url.search); fwd.delete("t");
|
|
2608
|
+
const qs = fwd.toString();
|
|
2609
|
+
const basePath = url.pathname.startsWith("/napcat/") ? url.pathname.slice("/napcat".length) : url.pathname;
|
|
2610
|
+
const targetPath = basePath + (qs ? "?" + qs : "");
|
|
2611
|
+
const opt = {
|
|
2612
|
+
host: "127.0.0.1",
|
|
2613
|
+
port: 6099,
|
|
2614
|
+
path: targetPath,
|
|
2615
|
+
method: req.method,
|
|
2616
|
+
headers: { ...req.headers, host: "127.0.0.1:6099" }
|
|
2617
|
+
};
|
|
2618
|
+
const preq = await import("node:http").then((m) => m.request);
|
|
2619
|
+
const p = preq(opt, (pres) => {
|
|
2620
|
+
const headers = { ...pres.headers };
|
|
2621
|
+
if (typeof headers.location === "string") {
|
|
2622
|
+
try {
|
|
2623
|
+
const u = new URL(headers.location, "http://127.0.0.1:6099");
|
|
2624
|
+
headers.location = "/napcat" + u.pathname + (u.search || "");
|
|
2625
|
+
} catch {}
|
|
2626
|
+
}
|
|
2627
|
+
res.writeHead(pres.statusCode || 502, headers);
|
|
2628
|
+
pres.pipe(res);
|
|
2629
|
+
});
|
|
2630
|
+
p.on("error", () => { try { res.writeHead(502).end("napcat webui unreachable"); } catch {} });
|
|
2631
|
+
req.pipe(p);
|
|
2632
|
+
return;
|
|
2633
|
+
}
|
|
2634
|
+
if (url.pathname === "/qr" || url.pathname === "/qrpage") {
|
|
2635
|
+
const qt = url.searchParams.get("t") || "";
|
|
2636
|
+
if (qt !== TOKEN) { res.writeHead(403).end("forbidden"); return; }
|
|
2637
|
+
if (url.pathname === "/qrpage") {
|
|
2638
|
+
res.writeHead(200, { "content-type": "text/html; charset=utf-8", "cache-control": "no-store" });
|
|
2639
|
+
res.end('<!doctype html><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>NapCat 登录</title><body style="background:#111;color:#ddd;font-family:system-ui;text-align:center;padding:20px"><h3>用 QQ 扫这张码登录</h3><div id="box"></div><p style="color:#888;font-size:13px">每 20 秒自动刷新,过期不用管</p><script>function r(){var i=new Image();i.onload=function(){document.getElementById("box").innerHTML="";document.getElementById("box").appendChild(i)};i.src="/qr?t=' + TOKEN + '&fresh=1&x="+Date.now();}r();setInterval(r,20000);</script></body>');
|
|
2640
|
+
return;
|
|
2641
|
+
}
|
|
2642
|
+
const __docker = existsSync("/usr/bin/docker") ? "/usr/bin/docker" : "docker";
|
|
2643
|
+
if (url.searchParams.get("fresh") === "1") {
|
|
2644
|
+
let __age = 1e9;
|
|
2645
|
+
try { __age = Date.now() - statSync("/opt/napcat/qrcode.png").mtimeMs; } catch {}
|
|
2646
|
+
let __connected = false;
|
|
2647
|
+
try {
|
|
2648
|
+
const __ss = await run("/usr/bin/ss", ["-tn", "state", "established", "( sport = :3001 )"]);
|
|
2649
|
+
__connected = String(__ss.stdout || "").trim().split("\n").length > 1;
|
|
2650
|
+
} catch {}
|
|
2651
|
+
if (__age > 100000 && !__connected) {
|
|
2652
|
+
const __stamp = "/tmp/napcat-qr-restart";
|
|
2653
|
+
let __last = 0;
|
|
2654
|
+
try { __last = statSync(__stamp).mtimeMs; } catch {}
|
|
2655
|
+
if (Date.now() - __last > 60000) {
|
|
2656
|
+
try { writeFileSync(__stamp, String(Date.now())); } catch {}
|
|
2657
|
+
await run(__docker, ["restart", "napcat"]);
|
|
2658
|
+
await new Promise((r2) => setTimeout(r2, 12000));
|
|
2659
|
+
}
|
|
2660
|
+
}
|
|
2661
|
+
}
|
|
2662
|
+
const r = await run(__docker, ["cp", "napcat:/app/napcat/cache/qrcode.png", "/opt/napcat/qrcode.png"]);
|
|
2663
|
+
if (!r.ok) { res.writeHead(500).end("qr unavailable"); return; }
|
|
2664
|
+
const buf = readFileSync("/opt/napcat/qrcode.png");
|
|
2665
|
+
res.writeHead(200, { "content-type": "image/png", "cache-control": "no-store" });
|
|
2666
|
+
res.end(buf);
|
|
2667
|
+
return;
|
|
2668
|
+
}
|
|
2669
|
+
|
|
2670
|
+
if (url.pathname === "/api/logs") {
|
|
2671
|
+
const r = await run("journalctl", ["-u", "dsh-qqbot", "--no-pager", "-n", "150"]);
|
|
2672
|
+
res.writeHead(200, { "content-type": "text/plain; charset=utf-8" });
|
|
2673
|
+
return res.end(r.stdout);
|
|
2674
|
+
}
|
|
2675
|
+
res.writeHead(404).end();
|
|
2676
|
+
} catch (e) {
|
|
2677
|
+
res.writeHead(500, { "content-type": "text/plain; charset=utf-8" });
|
|
2678
|
+
res.end(String(e));
|
|
2679
|
+
}
|
|
2680
|
+
});
|
|
2681
|
+
|
|
2682
|
+
return new Promise((resolve, reject) => {
|
|
2683
|
+
const start = Number(PORT) || 8090;
|
|
2684
|
+
let attempt = 0;
|
|
2685
|
+
const tryListen = () => {
|
|
2686
|
+
const port = start + attempt;
|
|
2687
|
+
const onError = (error) => {
|
|
2688
|
+
server.removeListener("error", onError);
|
|
2689
|
+
if (error && error.code === "EADDRINUSE" && attempt < 20) {
|
|
2690
|
+
attempt += 1;
|
|
2691
|
+
setTimeout(tryListen, 50);
|
|
2692
|
+
return;
|
|
2693
|
+
}
|
|
2694
|
+
reject(error);
|
|
2695
|
+
};
|
|
2696
|
+
server.on("error", onError);
|
|
2697
|
+
server.once("listening", () => {
|
|
2698
|
+
server.removeListener("error", onError);
|
|
2699
|
+
resolve({ port, close: () => new Promise((r) => server.close(() => r())) });
|
|
2700
|
+
});
|
|
2701
|
+
server.listen(port, HOST);
|
|
2702
|
+
};
|
|
2703
|
+
tryListen();
|
|
2704
|
+
});
|
|
2705
|
+
}
|