sintbot 1.0.16 → 1.3.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.
Files changed (137) hide show
  1. package/CHANGELOG.en.json +99 -0
  2. package/CHANGELOG.json +99 -0
  3. package/README.md +20 -158
  4. package/README.zh-CN.md +20 -166
  5. package/THIRD-PARTY-NOTICES.md +394 -0
  6. package/bin/sintbot-v2.js +363 -0
  7. package/docs/AI-ASSISTED-INSTALL.md +11 -35
  8. package/docs/install.md +61 -167
  9. package/docs/zh-CN/AI-ASSISTED-INSTALL.md +11 -35
  10. package/docs/zh-CN/install.md +61 -165
  11. package/install.sh +84 -0
  12. package/package.json +73 -37
  13. package/src/agent/claudecode.js +149 -108
  14. package/src/agent/codexapp.js +458 -0
  15. package/src/agent/codexcode.js +210 -107
  16. package/src/agent/errtag.js +34 -0
  17. package/src/agent/privatecore.js +790 -0
  18. package/src/agent/privatecore.wasm +0 -0
  19. package/src/agent/runner.js +10 -8
  20. package/src/agent/winexec.js +35 -5
  21. package/src/c2install.js +117 -0
  22. package/src/config.js +2 -1
  23. package/src/core/c2cardeffectbridge.js +51 -0
  24. package/src/core/c2cardterminalpath.js +216 -0
  25. package/src/core/c2durableingress.js +148 -0
  26. package/src/core/c2durablejournal.js +1560 -0
  27. package/src/core/c2durableruntime.js +2361 -0
  28. package/src/core/c2eventidentity.js +90 -0
  29. package/src/core/c2globalfault.js +401 -0
  30. package/src/core/c2localeffectexecutor.js +193 -0
  31. package/src/core/c2payloadhandles.js +333 -0
  32. package/src/core/c2persistrelation.js +179 -0
  33. package/src/core/c2persiststate.js +342 -0
  34. package/src/core/c2platformrecoverysender.js +76 -0
  35. package/src/core/c2recoverygate.js +385 -0
  36. package/src/core/c2runnerinput.js +63 -0
  37. package/src/core/c2servicestartgate.js +103 -0
  38. package/src/core/c2sessionbridge.js +72 -0
  39. package/src/core/c2sourcequarantine.js +30 -0
  40. package/src/core/c2startuprecovery.js +105 -0
  41. package/src/core/c2successorbridge.js +151 -0
  42. package/src/core/c2terminaleffectbridge.js +154 -0
  43. package/src/core/c2terminalprune.js +108 -0
  44. package/src/core/c2turnprojection.js +177 -0
  45. package/src/core/enginecheckpoint.js +211 -0
  46. package/src/core/engineeffectcursor.js +394 -0
  47. package/src/core/enginestepcoordinator.js +183 -0
  48. package/src/core/store.js +52 -8
  49. package/src/core/v2engine.js +1469 -0
  50. package/src/platform/feishu.js +324 -67
  51. package/src/plugins.js +1 -9
  52. package/assets/design-styles/acid-pop.md +0 -155
  53. package/assets/design-styles/archive-index.md +0 -161
  54. package/assets/design-styles/art-deco.md +0 -164
  55. package/assets/design-styles/cinematic-noir.md +0 -178
  56. package/assets/design-styles/editorial.md +0 -121
  57. package/assets/design-styles/extra/README.md +0 -23
  58. package/assets/design-styles/extra/constructivism.md +0 -146
  59. package/assets/design-styles/extra/neo-brutalism.md +0 -175
  60. package/assets/design-styles/extra/new-wave.md +0 -166
  61. package/assets/design-styles/extra/nordic-material.md +0 -162
  62. package/assets/design-styles/extra/paris-couture.md +0 -162
  63. package/assets/design-styles/extra/tropical-modern.md +0 -168
  64. package/assets/design-styles/extra/ulm-functional.md +0 -182
  65. package/assets/design-styles/extra/wa-shikimen.md +0 -160
  66. package/assets/design-styles/extra/y2k-chrome.md +0 -172
  67. package/assets/design-styles/geometric-pop.md +0 -122
  68. package/assets/design-styles/hand-drawn.md +0 -117
  69. package/assets/design-styles/islamic-geometry.md +0 -155
  70. package/assets/design-styles/liquid-glass.md +0 -183
  71. package/assets/design-styles/neo-chinese.md +0 -136
  72. package/assets/design-styles/precision-mono.md +0 -156
  73. package/assets/design-styles/quiet-luxury.md +0 -163
  74. package/assets/design-styles/riso-print.md +0 -116
  75. package/assets/design-styles/soft-friendly.md +0 -174
  76. package/assets/design-styles/swiss-grid.md +0 -156
  77. package/assets/design-styles/tech-dark.md +0 -182
  78. package/assets/favicon.svg +0 -6
  79. package/assets/logo.svg +0 -24
  80. package/bin/sbot.js +0 -4716
  81. package/docs/slack-app-manifest.yaml +0 -104
  82. package/sbot.config.example.json +0 -235
  83. package/src/agent/askonce.js +0 -76
  84. package/src/agent/claudemodels.js +0 -60
  85. package/src/agent/claudestatus.js +0 -74
  86. package/src/agent/claudeusage.js +0 -200
  87. package/src/agent/codexstatus.js +0 -181
  88. package/src/agent/imagen.js +0 -458
  89. package/src/agent/quotaerr.js +0 -44
  90. package/src/agent/transienterr.js +0 -32
  91. package/src/cdp.js +0 -254
  92. package/src/cli-secret.js +0 -36
  93. package/src/clilang.js +0 -31
  94. package/src/core/cron.js +0 -182
  95. package/src/core/cronfire.js +0 -120
  96. package/src/core/cronrun.js +0 -116
  97. package/src/core/engine.js +0 -2758
  98. package/src/core/interfaces.js +0 -114
  99. package/src/core/issues.js +0 -185
  100. package/src/core/ledger.js +0 -30
  101. package/src/core/opsalert.js +0 -214
  102. package/src/core/pending.js +0 -123
  103. package/src/core/proxy.js +0 -424
  104. package/src/core/quotaalert.js +0 -79
  105. package/src/core/quotaerr.js +0 -55
  106. package/src/core/scheduler.js +0 -59
  107. package/src/design.js +0 -5161
  108. package/src/gitrepo.js +0 -150
  109. package/src/health.js +0 -353
  110. package/src/installdeps.js +0 -72
  111. package/src/leftover.js +0 -174
  112. package/src/platform/boardnotice.js +0 -77
  113. package/src/platform/boardops.js +0 -36
  114. package/src/platform/boardwatch.js +0 -447
  115. package/src/platform/cli.js +0 -45
  116. package/src/platform/slack.js +0 -1289
  117. package/src/platform/takeovercard.js +0 -37
  118. package/src/platform/wecom.js +0 -938
  119. package/src/platform/wecomcli.js +0 -128
  120. package/src/platform/wecomgate.js +0 -108
  121. package/src/platform/wecomname.js +0 -43
  122. package/src/platform/wecompoll.js +0 -241
  123. package/src/preview.js +0 -581
  124. package/src/projectmemory.js +0 -175
  125. package/src/service.js +0 -317
  126. package/src/ui/auth.js +0 -31
  127. package/src/ui/browser.html +0 -392
  128. package/src/ui/browserroutes.js +0 -234
  129. package/src/ui/console.html +0 -5526
  130. package/src/ui/dashboard.js +0 -168
  131. package/src/ui/feishulogin.js +0 -136
  132. package/src/ui/server.js +0 -2314
  133. package/src/ui/sessions.js +0 -78
  134. package/src/ui/takeover.js +0 -108
  135. package/src/updater.js +0 -243
  136. package/src/upgrade.js +0 -35
  137. package/src/usage.js +0 -144
@@ -1,392 +0,0 @@
1
- <!doctype html>
2
- <html lang="zh-CN">
3
- <head>
4
- <meta charset="utf-8">
5
- <meta name="viewport" content="width=device-width,initial-scale=1,maximum-scale=1,viewport-fit=cover">
6
- <meta name="robots" content="noindex">
7
- <title>接管浏览器</title>
8
- <!-- 接管浏览器:把机器人常驻浏览器的画面推到这一页,人在手机 / 电脑上点、拖、输入,登录态落在机器人那边。
9
- 两种人会到这里:控制台管理员(从控制台进来,带令牌)与拿到一次性链接的群成员(只带 cookie,只能进这一页)。
10
- 页面按「身份 × 阶段」切模式(body[data-mode]):panel(持票未兑换 / 结束 / 过期 / 去登录:整屏一句话 + 一个动作,别的全藏)·
11
- live(接管中:画面 + 键盘;群成员只看到自己那一页,没有标签页下拉 / 新开 / 控制台入口)。
12
- 自包含单文件,配色 token 与 console.html 同一套值(两处各写一份 —— 这页故意不加载整份控制台:群成员不该
13
- 看到控制台其它页面,也不该为一个画面拉几百 KB 的脚本)。图标 = Lucide sprite(lucide-static v1.31.0,与控制台同版,内联)。 -->
14
- <style>
15
- :root{
16
- --bg:#0f0f13;--panel:rgba(205,214,244,.045);--ink:#cdd6f4;--dim:#a6adc8;--faint:#9399b2;--line:rgba(205,214,244,.16);
17
- --ember:#cba6f7;--ember-rgb:203,166,247;--on-ember:#11111b;--ok:#a6e3a1;--warn:#f9e2af;--fail:#f38ba8;--fail-rgb:243,139,168;
18
- --btn-bg:rgba(255,255,255,.055);--input-bg:rgba(0,0,0,.28);--stage:#000;
19
- --sans:-apple-system,BlinkMacSystemFont,'SF Pro SC','SF Pro Text','PingFang SC','Hiragino Sans GB','Source Han Sans SC','Noto Sans SC','Microsoft YaHei UI','Microsoft YaHei',sans-serif;
20
- --mono:'JetBrains Mono','Monaco',ui-monospace,SFMono-Regular,Menlo,'PingFang SC',monospace;
21
- }
22
- :root[data-theme="light"]{
23
- --bg:#f7f8fc;--panel:#ffffff;--ink:#172033;--dim:#3b4557;--faint:#5b667a;--line:#e2e8f0;
24
- --ember:#6d28d9;--ember-rgb:109,40,217;--on-ember:#fff;--ok:#6ea81a;--warn:#df8e1d;--fail:#e55934;--fail-rgb:229,89,52;
25
- --btn-bg:rgba(23,32,51,.06);--input-bg:rgba(23,32,51,.07);--stage:#e9ecf3;
26
- }
27
- *{box-sizing:border-box}
28
- /* ⚠ hidden 属性只有浏览器默认样式在撑,任何一条给同一元素写 display 的规则都会把它覆盖掉 —— 真机截图逮到:群成员看到了
29
- 本该只给管理员的「控制台」链接(a.back 写了 display:inline-flex)。全局兜底一次,以后新加的元素不必逐个记得写 [hidden] */
30
- [hidden]{display:none!important}
31
- html,body{height:100%}
32
- body{margin:0;background:var(--bg);color:var(--ink);font:13px/1.5 var(--sans);display:flex;flex-direction:column;overflow:hidden}
33
- /* 模式切换:只有 live 显示工具栏 / 画面 / 键盘;其余模式只显示整屏面板 */
34
- body:not([data-mode="live"]) .top,body:not([data-mode="live"]) #stage,body:not([data-mode="live"]) .kb{display:none}
35
- body[data-mode="live"] #panel{display:none}
36
- .top,.kb{flex:none;display:flex;align-items:center;gap:8px;padding:8px 10px;border-bottom:1px solid var(--line);background:var(--panel);flex-wrap:wrap}
37
- .kb{border-bottom:none;border-top:1px solid var(--line);padding-bottom:calc(8px + env(safe-area-inset-bottom))}
38
- .sp{flex:1}
39
- select,input,button{font:inherit;color:inherit}
40
- select{max-width:min(46vw,320px);height:34px;padding:0 8px;border:1px solid var(--line);border-radius:8px;background:var(--input-bg)}
41
- input{flex:1;min-width:140px;height:36px;padding:0 11px;border:1px solid var(--line);border-radius:8px;background:var(--input-bg);outline:none}
42
- input:focus-visible,button:focus-visible,select:focus-visible{outline:2px solid var(--ember);outline-offset:1px}
43
- button{height:34px;padding:0 12px;border:1px solid var(--line);border-radius:8px;background:var(--btn-bg);cursor:pointer;white-space:nowrap;transition:color .16s,background-color .16s,border-color .16s}
44
- button:hover{border-color:rgba(var(--ember-rgb),.5)}
45
- button.primary{background:var(--ember);color:var(--on-ember);border-color:transparent;font-weight:600}
46
- button.danger{color:var(--fail);border-color:rgba(var(--fail-rgb),.45)}
47
- button.on{background:rgba(var(--ember-rgb),.14);border-color:rgba(var(--ember-rgb),.6);color:var(--ember)}
48
- .iconbtn{width:34px;padding:0;display:inline-flex;align-items:center;justify-content:center}
49
- .ic{width:16px;height:16px;stroke:currentColor;fill:none;stroke-width:2;stroke-linecap:round;stroke-linejoin:round;display:block}
50
- a.back{color:var(--dim);text-decoration:none;font-weight:600;display:inline-flex;align-items:center;gap:6px}
51
- .ptitle{font-weight:600;max-width:min(52vw,360px);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
52
- .st{font-size:11.5px;color:var(--faint);max-width:100%;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
53
- .st.bad{color:var(--fail)} .st.ok{color:var(--ok)}
54
- .url{font:11px var(--mono);color:var(--faint);width:100%;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
55
- #stage{flex:1;min-height:0;position:relative;background:var(--stage);overflow:hidden;touch-action:none;-webkit-user-select:none;user-select:none;display:flex;align-items:flex-start;justify-content:center}
56
- /* 宽度钉成容器宽度(不是 max-width):帧的像素宽会随 maxWidth / 视口变,钉住显示宽度,画面才不会在两帧之间挪位置 —— 手指正按着时挪一下,坐标就漂了 */
57
- #frame{display:block;width:100%;height:auto;-webkit-user-drag:none;pointer-events:none;transform-origin:0 0;will-change:transform}
58
- #frame:not([src]){display:none}
59
- #hint{position:absolute;inset:0;display:flex;align-items:center;justify-content:center;padding:24px;text-align:center;color:var(--dim);line-height:1.8;pointer-events:none}
60
- /* ⚠ 提示层整体 pointer-events:none 是为了让画面上的手势穿过它;层里可点的东西要逐个放开 —— 只放了 a 没放 button,
61
- 手机上真手指点「开始接管」直接穿透、什么都不发生(2026-09-10 真机;桌面用程序化 click() 绕过了这一层所以没测出来) */
62
- #hint a,#hint button{color:var(--ember);pointer-events:auto}
63
- #hint button.primary{color:var(--on-ember)}
64
- /* 首帧引导:接管中第一次看到画面时飘一句怎么用,手指一碰就收 */
65
- #tip{position:absolute;left:50%;bottom:14px;transform:translateX(-50%);background:rgba(0,0,0,.72);color:#fff;font-size:12px;padding:8px 14px;border-radius:20px;white-space:nowrap;pointer-events:none;transition:opacity .4s}
66
- #tip.fade{opacity:0}
67
- /* 缩放控件:在顶栏、不在画面里(浮在画面右上角时正好压住网页自己右上角的按钮 —— 多数站点的「设置 / 登录」都在那儿,
68
- 点过去落在控件上而不是网页上)。凡叠在画面上的东西都会抢走它底下那块的点击,所以控件一律放画面区外。没画面时靠 body.streaming 藏。
69
- 手机上只留「100%」一枚(既是指示也是还原;缩放靠双指捏合,首帧提示已教),− / + 只给桌面 —— 窄屏顶栏挤不下三枚。 */
70
- #zoom{display:inline-flex;align-items:center;border:1px solid var(--line);border-radius:8px;overflow:hidden;background:var(--btn-bg)}
71
- body:not(.streaming) #zoom{display:none}
72
- #zoom button{height:32px;min-width:32px;padding:0 8px;border:none;border-radius:0;background:transparent;font-size:16px}
73
- #zoom button+button{border-left:1px solid var(--line)}
74
- #zoom button#zoomv{font-size:12px;min-width:52px;font-variant-numeric:tabular-nums}
75
- #zoom.on #zoomv{color:var(--ember);background:rgba(var(--ember-rgb),.14)}
76
- .keys{display:flex;gap:6px;flex-wrap:wrap;width:100%}
77
- .keys button{height:32px;padding:0 10px;font-size:12px;display:inline-flex;align-items:center;justify-content:center;flex:1;min-width:38px}
78
- /* 整屏面板(欢迎 / 结束 / 过期 / 去登录):居中、一句话、一个动作 */
79
- #panel{flex:1;display:flex;align-items:center;justify-content:center;padding:32px 24px calc(32px + env(safe-area-inset-bottom))}
80
- #panel .pbox{width:min(420px,100%);text-align:center}
81
- #panel .pico{width:56px;height:56px;border-radius:16px;margin:0 auto 18px;display:flex;align-items:center;justify-content:center;background:rgba(var(--ember-rgb),.12);color:var(--ember)}
82
- #panel .pico .ic{width:26px;height:26px}
83
- #panel .pico.grey{background:rgba(127,127,127,.12);color:var(--faint)}
84
- #panel h1{font-size:20px;margin:0 0 10px;line-height:1.35}
85
- #panel p{margin:0 0 22px;color:var(--dim);line-height:1.8;font-size:14px}
86
- #panel button.primary,#panel a.primary{height:46px;padding:0 28px;font-size:15px;border-radius:12px;width:100%;display:inline-flex;align-items:center;justify-content:center;text-decoration:none;background:var(--ember);color:var(--on-ember);border:none;font-weight:600}
87
- #panel .note{margin-top:14px;font-size:12px;color:var(--faint)}
88
- #panel .err{margin-top:12px;font-size:13px;color:var(--fail);min-height:1.2em}
89
- .mask{position:fixed;inset:0;background:rgba(0,0,0,.5);display:flex;align-items:center;justify-content:center;padding:16px;z-index:9}
90
- .mask[hidden]{display:none}
91
- .card{background:var(--bg);border:1px solid var(--line);border-radius:12px;padding:18px;width:min(520px,100%);box-shadow:0 10px 28px rgba(0,0,0,.3)}
92
- .card h3{margin:0 0 10px;font-size:15px}
93
- .card .lnk{font:12px var(--mono);word-break:break-all;background:var(--input-bg);padding:10px;border-radius:8px;border:1px solid var(--line);user-select:all}
94
- .card .row{display:flex;gap:8px;margin-top:12px;justify-content:flex-end;flex-wrap:wrap}
95
- .hint2{font-size:11.5px;color:var(--faint);margin-top:8px;line-height:1.7}
96
- @media (max-width:520px){ select{max-width:100%;width:100%} .top{gap:6px} button{padding:0 10px} .ptitle{max-width:100%;width:100%} #zoom [data-z]{display:none} #zoom button+button{border-left:none} }
97
- </style>
98
- <script>document.documentElement.dataset.theme=localStorage.getItem('sbot-theme')||'light'</script>
99
- </head>
100
- <body data-mode="boot">
101
- <section id="panel"><div class="pbox" id="pbox"></div></section>
102
- <header class="top">
103
- <a class="back" id="back" href="/" hidden>控制台</a>
104
- <select id="tabs" aria-label="标签页"><option value="">读取标签页…</option></select>
105
- <span class="ptitle" id="ptitle" hidden></span>
106
- <button class="iconbtn" id="reload" title="重新读取标签页" aria-label="重新读取标签页"><svg class="ic"><use href="#lu-refresh"/></svg></button>
107
- <button class="iconbtn" id="newtab" title="新开标签页" aria-label="新开标签页"><svg class="ic"><use href="#lu-plus"/></svg></button>
108
- <span class="sp"></span>
109
- <button id="mode" title="点按:手指 / 鼠标动作原样传给页面(能拖滑块)。点一下换成「滚动」">点按</button>
110
- <div id="zoom" title="画面缩放(手机上双指捏合)"><button data-z="out" aria-label="缩小" title="缩小">−</button><button id="zoomv" title="还原到适应宽度">100%</button><button data-z="in" aria-label="放大" title="放大">+</button></div>
111
- <button id="mklink" hidden title="生成一条只能打开一次的接管链接,发给群里要登录的人"><svg class="ic" style="display:inline;vertical-align:-3px;margin-right:4px"><use href="#lu-link"/></svg>接管链接</button>
112
- <button id="end" class="danger" hidden>结束接管</button>
113
- <button class="iconbtn" id="theme" title="切换亮/暗主题" aria-label="切换主题"><svg class="ic"><use href="#lu-sun"/></svg></button>
114
- <span class="st" id="st">连接中…</span>
115
- <span class="url" id="url"></span>
116
- </header>
117
- <main id="stage">
118
- <img id="frame" alt="">
119
- <div id="hint" hidden></div>
120
- <div id="tip" hidden>一指点、拖 = 操作页面;两指捏合缩放、两指拖动移动;打字用下面的输入框</div>
121
- </main>
122
- <footer class="kb">
123
- <input id="txt" placeholder="输入文字,回车 = 发到页面并按回车" autocomplete="off" autocapitalize="off">
124
- <button id="send" class="primary">发送</button>
125
- <div class="keys">
126
- <button data-key="Enter" title="回车"><svg class="ic"><use href="#lu-enter"/></svg></button>
127
- <button data-key="Backspace" title="退格"><svg class="ic"><use href="#lu-backspace"/></svg></button>
128
- <button data-key="Tab">Tab</button><button data-key="Escape">Esc</button>
129
- <button data-key="ArrowUp" title="上" aria-label="上"><svg class="ic"><use href="#lu-up"/></svg></button><button data-key="ArrowDown" title="下" aria-label="下"><svg class="ic"><use href="#lu-down"/></svg></button><button data-key="ArrowLeft" title="左" aria-label="左"><svg class="ic"><use href="#lu-left"/></svg></button><button data-key="ArrowRight" title="右" aria-label="右"><svg class="ic"><use href="#lu-right"/></svg></button>
130
- </div>
131
- </footer>
132
- <div class="mask" id="mask" hidden><div class="card" id="card"></div></div>
133
- <svg style="display:none" aria-hidden="true" xmlns="http://www.w3.org/2000/svg">
134
- <symbol id="lu-refresh" viewBox="0 0 24 24"><path d="M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8"/><path d="M21 3v5h-5"/><path d="M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16"/><path d="M8 16H3v5"/></symbol>
135
- <symbol id="lu-plus" viewBox="0 0 24 24"><path d="M5 12h14"/><path d="M12 5v14"/></symbol>
136
- <symbol id="lu-link" viewBox="0 0 24 24"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/></symbol>
137
- <symbol id="lu-enter" viewBox="0 0 24 24"><path d="M20 4v7a4 4 0 0 1-4 4H4"/><path d="m9 10-5 5 5 5"/></symbol>
138
- <symbol id="lu-backspace" viewBox="0 0 24 24"><path d="M10 5a2 2 0 0 0-1.344.519l-6.328 5.74a1 1 0 0 0 0 1.481l6.328 5.741A2 2 0 0 0 10 19h10a2 2 0 0 0 2-2V7a2 2 0 0 0-2-2z"/><path d="m12 9 6 6"/><path d="m18 9-6 6"/></symbol>
139
- <symbol id="lu-sun" viewBox="0 0 24 24"><circle cx="12" cy="12" r="4"/><path d="M12 2v2"/><path d="M12 20v2"/><path d="m4.93 4.93 1.41 1.41"/><path d="m17.66 17.66 1.41 1.41"/><path d="M2 12h2"/><path d="M20 12h2"/><path d="m6.34 17.66-1.41 1.41"/><path d="m19.07 4.93-1.41 1.41"/></symbol>
140
- <symbol id="lu-moon" viewBox="0 0 24 24"><path d="M20.985 12.486a9 9 0 1 1-9.473-9.472c.405-.022.617.46.402.803a6 6 0 0 0 8.268 8.268c.344-.215.825-.004.803.401"/></symbol>
141
- <symbol id="lu-up" viewBox="0 0 24 24"><path d="m5 12 7-7 7 7"/><path d="M12 19V5"/></symbol>
142
- <symbol id="lu-down" viewBox="0 0 24 24"><path d="M12 5v14"/><path d="m19 12-7 7-7-7"/></symbol>
143
- <symbol id="lu-left" viewBox="0 0 24 24"><path d="m12 19-7-7 7-7"/><path d="M19 12H5"/></symbol>
144
- <symbol id="lu-right" viewBox="0 0 24 24"><path d="M5 12h14"/><path d="m12 5 7 7-7 7"/></symbol>
145
- <symbol id="lu-hand" viewBox="0 0 24 24"><path d="M18 11V6a2 2 0 0 0-2-2a2 2 0 0 0-2 2"/><path d="M14 10V4a2 2 0 0 0-2-2a2 2 0 0 0-2 2v2"/><path d="M10 10.5V6a2 2 0 0 0-2-2a2 2 0 0 0-2 2v8"/><path d="M18 8a2 2 0 1 1 4 0v6a8 8 0 0 1-8 8h-2c-2.8 0-4.5-.86-5.99-2.34l-3.6-3.6a2 2 0 0 1 2.83-2.82L7 15"/></symbol>
146
- <symbol id="lu-check" viewBox="0 0 24 24"><path d="M20 6 9 17l-5-5"/></symbol>
147
- <symbol id="lu-clock" viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"/><path d="M12 6v6l4 2"/></symbol>
148
- <symbol id="lu-lock" viewBox="0 0 24 24"><rect width="18" height="11" x="3" y="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></symbol>
149
- </svg>
150
- <script>
151
- /* 令牌来源与 console.html 同一套:本机环回免密时服务端注入;否则读登录时存下的 localStorage。
152
- 都没有 ⇒ 看服务端注入的接管标记:'1' = 带着有效的接管 cookie 进来的群成员(只能到这一页)。 */
153
- let TOKEN='__SBOT_TOKEN__';
154
- const TAKEOVER='__SBOT_TAKEOVER__'==='1';
155
- /* 票在 URL 片段里(#k=…):服务器收不到它,链接预览 / 安全扫描的裸 GET 也拿不走;人点「开始接管」才拿去兑换 cookie */
156
- const ticket=(()=>{const m=(location.hash||'').match(/[#&]k=([0-9a-f]{16,128})/i)||(location.search||'').match(/[?&]k=([0-9a-f]{16,128})/i);return m?m[1]:''})();
157
- TOKEN=TOKEN||localStorage.getItem('sbot-token')||sessionStorage.getItem('sbot-token')||'';
158
- /* ⚠ 手里有票(或人已经在接管会话里)就一律按接管走,丢掉浏览器里存着的控制台令牌 —— 否则在存过令牌的电脑上点同一条
159
- 链接会变成管理员布局:既没有「开始接管」也没有「结束接管」;那枚令牌要是还过期了,整页只剩一句 unauthorized、
160
- 画面都出不来、什么都点不动(2026-09-10 真机复现,手机上没存过令牌所以一直正常)。票是当下最明确的意图,它说了算。 */
161
- if(ticket||TAKEOVER) TOKEN='';
162
- const ADMIN=!!TOKEN;
163
- const $=(s)=>document.querySelector(s);
164
- const st=$('#st'),img=$('#frame'),stage=$('#stage'),hint=$('#hint'),tip=$('#tip'),sel=$('#tabs'),ptitle=$('#ptitle'),urlEl=$('#url'),txt=$('#txt'),pbox=$('#pbox');
165
- const H=()=>TOKEN?{'X-Sbot-Token':TOKEN}:{};
166
- const setSt=(s,cls)=>{st.textContent=s;st.className='st'+(cls?' '+cls:'')};
167
- const setMode=(m)=>{document.body.dataset.mode=m};
168
- const esc=(s)=>String(s).replace(/[<>&]/g,c=>({'<':'&lt;','>':'&gt;','&':'&amp;'}[c]));
169
- /* 整屏面板:icon + 标题 + 一句话 + (可选)动作 + 小字 */
170
- const panel=({icon='hand',grey=false,title,text,action='',note=''})=>{ setMode('panel'); pbox.innerHTML=`<div class="pico${grey?' grey':''}"><svg class="ic"><use href="#lu-${icon}"/></svg></div><h1>${title}</h1><p>${text}</p>${action}${note?`<div class="note">${note}</div>`:''}<div class="err" id="perr"></div>` };
171
- /* 群成员的会话到期(或被结束)⇒ 整屏说清楚,别露服务端那句「unauthorized — 重新打开控制台页面」(群成员没有控制台)。推流 401 与接口 401 两条路都走这里 */
172
- const expired=()=>{ stopStream(); panel({icon:'clock',grey:true,title:'这次接管已到期',text:'链接只在发出后的几分钟内有效。还需要的话,回群里让机器人重新发一条。'}) };
173
- async function api(path,method='GET',obj){
174
- const r=await fetch(path,{method,headers:{...H(),...(obj?{'Content-Type':'application/json'}:{})},body:obj?JSON.stringify(obj):undefined,credentials:'same-origin'});
175
- const j=await r.json().catch(()=>({}));
176
- if(r.status===401&&TAKEOVER&&!ADMIN){ expired(); throw new Error('接管已到期'); }
177
- // 存着的控制台令牌过期了:清掉再请人登录 —— 留着它下次进来还是同一句 unauthorized、画面都出不来
178
- if(r.status===401&&ADMIN){ try{localStorage.removeItem('sbot-token');sessionStorage.removeItem('sbot-token')}catch{} stopStream(); panel({icon:'lock',grey:true,title:'这一页需要先登录',text:'控制台的登录已经过期。重新登录后再进来,或者用群里机器人发的链接打开。',action:'<a class="primary" href="/">去登录</a>'}); throw new Error('登录已过期'); }
179
- if(!r.ok)throw new Error(j.error||('HTTP '+r.status));
180
- return j;
181
- }
182
- /* 主题:与控制台共用同一个 localStorage 键,两边切换互相看得见 */
183
- const themeBtn=$('#theme');
184
- const paintTheme=()=>{themeBtn.innerHTML=`<svg class="ic"><use href="#${document.documentElement.dataset.theme==='light'?'lu-moon':'lu-sun'}"/></svg>`};
185
- themeBtn.onclick=()=>{const t=document.documentElement.dataset.theme==='light'?'dark':'light';document.documentElement.dataset.theme=t;localStorage.setItem('sbot-theme',t);paintTheme()};
186
- paintTheme();
187
- if(ADMIN){$('#back').hidden=false;$('#mklink').hidden=false}
188
- if(TAKEOVER&&!ADMIN){$('#end').hidden=false}
189
-
190
- if(!ADMIN&&!TAKEOVER){
191
- if(ticket){
192
- panel({icon:'hand',title:'机器人请你帮它一把',text:'它卡在一个需要真人的步骤 —— 登录、验证码、滑块或扫码。点下面开始,之后就在这一页上直接操作它的浏览器,做完回群里说一声。',action:'<button id="claim" class="primary">开始接管</button>',note:'链接只能用一次,几分钟内有效'});
193
- $('#claim').onclick=async()=>{
194
- const b=$('#claim'); b.disabled=true; b.textContent='正在连接…';
195
- try{ await api('/api/browser/claim','POST',{k:ticket}); history.replaceState(null,'',location.pathname); location.reload(); }
196
- catch(e){ b.disabled=false; b.textContent='开始接管'; $('#perr').textContent=e.message; }
197
- };
198
- } else {
199
- panel({icon:'lock',grey:true,title:'这一页需要先登录',text:'从控制台登录后再进来,或者用群里机器人发的链接打开。',action:'<a class="primary" href="/">去登录</a>'});
200
- }
201
- }
202
-
203
- /* ── 标签页 ── */
204
- let curId='',bound='';
205
- async function loadTargets(){
206
- let j; try{ j=await api('/api/browser/targets') }catch(e){ setSt(e.message,'bad'); return }
207
- setMode('live');
208
- if(!j.ok){ setSt(j.error||'浏览器没连上','bad'); hint.hidden=false; hint.textContent=j.error||'浏览器没连上'; sel.innerHTML='<option value="">(没有标签页)</option>'; return }
209
- bound=j.bound||'';
210
- if(j.mode==='takeover'){ $('#newtab').hidden=true; $('#reload').hidden=!!bound; } // 票不是「随便开网页」的许可(服务端同样拒);绑了单页连刷新列表都不需要
211
- // 绑了单页:不显示下拉,只显示页面标题 —— 群成员没有「选标签页」这件事
212
- if(bound&&j.targets.length===1){ sel.hidden=true; ptitle.hidden=false; ptitle.textContent=j.targets[0].title||j.targets[0].url||'页面'; }
213
- else { sel.hidden=false; ptitle.hidden=true; }
214
- const remembered=sessionStorage.getItem('sbot-takeover-tab')||'';
215
- sel.innerHTML=j.targets.length?j.targets.map(t=>`<option value="${t.id}">${esc((t.title||t.url||'(空白页)').slice(0,60))}</option>`).join(''):'<option value="">(没有标签页,点 + 新开)</option>';
216
- const pick=j.targets.find(t=>t.id===remembered)||j.targets.find(t=>!/^about:blank$/.test(t.url))||j.targets[0];
217
- if(pick){ sel.value=pick.id; if(pick.id!==curId) startStream(pick.id) } else { stopStream(); setSt('浏览器里没有标签页'); }
218
- }
219
- sel.onchange=()=>{ if(sel.value) startStream(sel.value) };
220
- $('#reload').onclick=loadTargets;
221
- $('#newtab').onclick=async()=>{
222
- const u=window.prompt('要打开的网址(http 或 https 开头)','https://');
223
- if(!u)return;
224
- try{ const r=await api('/api/browser/targets','POST',{url:u}); await loadTargets(); if(r.id){ sel.value=r.id; startStream(r.id) } }catch(e){ setSt(e.message,'bad') }
225
- };
226
-
227
- /* ── 画面:服务端按 SSE 形状写,这里用 fetch 流式读(令牌走请求头,不进 URL) ── */
228
- let ac=null,meta={w:0,h:0},frames=0,tipShown=false;
229
- let zoom=1,tx=0,ty=0,streamW=0,rezoomTimer=null; // 本地缩放 / 平移(只动 CSS transform)与当前推流宽度
230
- /* 推流宽度上限 1024(2026-09-15 从 1600 压下来):帧和输入走同一条公网隧道,桌面按容器宽 × dpr 要到 1440~1600 时一帧约 40 KB、拖动中连续出帧,
231
- 把隧道发送队列塞满后输入的响应排在帧后面,每个事件 1.7~2 秒、滑块拖不动(隧道退化时尤甚);手机本来只要 780 左右所以一直没事。
232
- 1024 把桌面每帧砍掉约一半给输入让路,1280 宽的页面缩到 80% 仍看得清;放大时也不再超过它 —— 用一点清晰度换拖得动。 */
233
- const STREAM_W_MAX=1024;
234
- const W=()=>Math.min(STREAM_W_MAX,Math.max(320,Math.round(stage.clientWidth*Math.min(2,window.devicePixelRatio||1)*zoom))); // 放大了就按放大后的尺寸要帧(CDP 不会放大超过页面本身的宽)
235
- function stopStream(keepView){ if(ac){ac.abort();ac=null} curId=''; if(!keepView){ img.removeAttribute('src'); document.body.classList.remove('streaming') } }
236
- async function startStream(id,{keepView=false}={}){
237
- stopStream(keepView); curId=id; if(!keepView){ zoom=1; tx=0; ty=0; applyView() } sessionStorage.setItem('sbot-takeover-tab',id); frames=0; meta={w:0,h:0};
238
- hint.hidden=true; setSt('连接中…'); urlEl.textContent='';
239
- api('/api/browser/targets/activate','POST',{id}).catch(()=>{}); // 机器旁边的人也能看到这一页(失败无所谓)
240
- const mine=new AbortController(); ac=mine;
241
- try{
242
- // q=45:手机上一帧约 12 KB,文字仍可读;想更清晰把它调高,代价是流量翻倍
243
- streamW=W(); const r=await fetch(`/api/browser/stream?id=${encodeURIComponent(id)}&w=${streamW}&q=45`,{headers:H(),signal:mine.signal,credentials:'same-origin'});
244
- if(!r.ok){ const j=await r.json().catch(()=>({})); if(r.status===401&&TAKEOVER&&!ADMIN){ expired(); return } setSt(j.error||('连不上:'+r.status),'bad'); hint.hidden=false; hint.textContent=j.error||'连不上浏览器'; return }
245
- const rd=r.body.getReader(),dec=new TextDecoder(); let buf='';
246
- while(true){
247
- const {value,done}=await rd.read(); if(done)break;
248
- buf+=dec.decode(value,{stream:true});
249
- let i; while((i=buf.indexOf('\n\n'))>=0){ onEvent(buf.slice(0,i)); buf=buf.slice(i+2) }
250
- }
251
- if(ac===mine){ setSt('画面断了,重连中…','bad'); setTimeout(()=>{ if(ac===mine) startStream(id) },1500) }
252
- }catch(e){
253
- if(ac===mine&&!mine.signal.aborted){ setSt('画面断了:'+e.message,'bad'); setTimeout(()=>{ if(ac===mine) startStream(id) },2000) }
254
- }
255
- }
256
- function onEvent(chunk){
257
- let ev='message',data='';
258
- for(const line of chunk.split('\n')){ if(line.startsWith('event:'))ev=line.slice(6).trim(); else if(line.startsWith('data:'))data+=line.slice(5).trim() }
259
- let j; try{ j=JSON.parse(data) }catch{ return }
260
- if(ev==='frame'){
261
- frames++; if(j.w&&j.h){meta.w=j.w;meta.h=j.h}
262
- img.src='data:image/jpeg;base64,'+j.b64; document.body.classList.add('streaming'); // 顶栏的缩放控件只在有画面时显示
263
- // 状态文案给人看:群成员只需要知道「可以动手了」;尺寸与帧数是排障信息,管理员才显示
264
- setSt(ADMIN?`画面 ${meta.w}×${meta.h} · ${frames} 帧`:'可以操作了','ok');
265
- document.body.dataset.frames=String(frames); // headless 验证读这个
266
- if(!tipShown&&!ADMIN){ tipShown=true; tip.hidden=false; setTimeout(()=>tip.classList.add('fade'),4500); }
267
- } else if(ev==='meta'){ if(j.url) urlEl.textContent=j.url; if(j.title&&bound) ptitle.textContent=j.title; }
268
- else if(ev==='error'){ setSt(j.error||'出错','bad') }
269
- }
270
-
271
- /* ── 输入:坐标从帧图片换算成页面 CSS 像素;move 合并成 33ms 一批,down/up 立刻发且保持顺序 ── */
272
- /* move 攒全部中间点、整批发出(2026-09-15)。此前 pendingMove 是单个变量、每来一个 move 就覆盖:请求在飞的那段时间里
273
- (公网隧道一个往返约 160 ms)所有中间点全丢 —— 实测 30 步拖动到服务端只剩 5 个点,轨迹成了跳跃,验证码的轨迹检测不认;
274
- 局域网往返 10 ms 才显不出来。攒下来整批发、服务端按序回放,轨迹形状就保住了。上限 MOVE_KEEP:超过就隔一个抽一个
275
- (保形状不保密度,最后一个点必留),免得一次长拖攒出几百条撞上服务端一批 200 条的上限。 */
276
- const q=[]; let flushing=false,moveTimer=null,pendingMoves=[]; const MOVE_KEEP=60;
277
- const takeMoves=()=>{ if(pendingMoves.length){ q.push(...pendingMoves); pendingMoves=[] } };
278
- async function flush(){
279
- if(flushing||!curId)return; takeMoves();
280
- if(!q.length)return;
281
- flushing=true; const batch=q.splice(0,q.length);
282
- try{ await api('/api/browser/input','POST',{id:curId,events:batch}) }catch(e){ setSt('输入没送到:'+e.message,'bad') }
283
- finally{ flushing=false; if(q.length||pendingMoves.length) flush() }
284
- }
285
- const push=(ev)=>{ q.push(ev); flush() };
286
- function map(e){ const r=img.getBoundingClientRect(); if(!meta.w||!r.width)return null; return { x:Math.round(Math.min(meta.w,Math.max(0,(e.clientX-r.left)/r.width*meta.w))*10)/10, y:Math.round(Math.min(meta.h,Math.max(0,(e.clientY-r.top)/r.height*meta.h))*10)/10 } }
287
- /* ── 缩放 / 移动:bot 那边多半是 1280 宽的桌面页,手机上一屏缩到 390 看不清 ⇒ 两指捏合缩放、两指拖动移动(一指仍归页面);
288
- 「移动」模式下一指拖动也是移动;桌面 ctrl+滚轮(触控板捏合)缩放;右上角 − / + / 百分比(点百分比还原)。
289
- 只动 CSS transform(transform-origin 左上);map() 读的是 getBoundingClientRect = 变换后的矩形,坐标换算不用改 ── */
290
- const zoomBox=$('#zoom'),zoomLabel=$('#zoomv');
291
- function applyView(){
292
- if(zoom<=1){ zoom=1; tx=0; ty=0 }
293
- const sw=stage.clientWidth,sh=stage.clientHeight,w=img.clientWidth*zoom,h=img.clientHeight*zoom;
294
- tx=Math.min(0,Math.max(Math.min(0,sw-w),tx)); ty=Math.min(0,Math.max(Math.min(0,sh-h),ty)); // 夹住:画面不能拖出视口;比视口矮就贴顶
295
- img.style.transform=zoom===1?'':`translate(${tx}px,${ty}px) scale(${zoom})`;
296
- zoomLabel.textContent=Math.round(zoom*100)+'%'; zoomBox.classList.toggle('on',zoom>1);
297
- // 放大到一定程度就按新尺寸重开推流(按手机宽要的 780px 帧放大三倍就糊了);600ms 没再动才重开,画面不清空
298
- // ⚠ 帧宽到了页面原宽就别再为「放大」重开:上一路要 1600 却只回来 1280(页面本身就 1280 宽)⇒ 再要更大也不会更清楚,只会多一次断流重连
299
- // (真机:桌面 ctrl+滚轮一步步放大,90 秒重开了 6 路);缩小(要更小的帧省流量)照旧
300
- clearTimeout(rezoomTimer); rezoomTimer=setTimeout(()=>{ if(!curId||!streamW)return; const need=W(),capped=meta.w>0&&meta.w<streamW-2; if(need>streamW&&capped)return; if(Math.abs(need-streamW)/streamW>0.3) startStream(curId,{keepView:true}) },600);
301
- }
302
- function zoomAt(f,cx,cy){ const z=Math.min(5,Math.max(1,zoom*f)),k=z/zoom; tx=cx-(cx-tx)*k; ty=cy-(cy-ty)*k; zoom=z; applyView() } // 以 stage 内 (cx,cy) 为锚:锚点下的画面缩放前后不动
303
- zoomBox.addEventListener('click',e=>{ const b=e.target.closest('button'); if(!b)return; const sw=stage.clientWidth/2,sh=stage.clientHeight/2; if(b.dataset.z==='in')zoomAt(1.5,sw,sh); else if(b.dataset.z==='out')zoomAt(1/1.5,sw,sh); else{ zoom=1; applyView() } });
304
- window.addEventListener('resize',()=>applyView());
305
- /* 模式:点按(一指 = 页面动作)· 滚动(一指上下 = 页面滚动)· 移动(一指 = 拖画面) */
306
- let mode='tap'; const MODES={tap:'点按',scroll:'滚动',pan:'移动'};
307
- const MODE_TITLES={tap:'点按:手指 / 鼠标动作原样传给页面(能拖滑块)。点一下换成「滚动」',scroll:'滚动:上下拖动变成页面滚动。点一下换成「移动」',pan:'移动:一指拖动放大后的画面、滚轮缩放。点一下换回「点按」'};
308
- $('#mode').onclick=()=>{ mode=mode==='tap'?'scroll':mode==='scroll'?'pan':'tap'; $('#mode').textContent=MODES[mode]; $('#mode').title=MODE_TITLES[mode]; $('#mode').classList.toggle('on',mode!=='tap') };
309
- /* 指针:ptrs 记着所有按着的手指。一指按下不立刻发 down —— 等 120ms 或手指动了才发:第二根手指在这窗口里落下就是捏合,页面不该收到一次点击 */
310
- const ptrs=new Map(); let down=false,drag=null,pend=null,gesture=null,pan=null,lastP=null;
311
- const commitDown=()=>{ if(!pend)return; const p=pend.p; clearTimeout(pend.timer); pend=null; down=true; lastP=p; push({t:'down',...p}) };
312
- stage.addEventListener('pointerdown',e=>{
313
- if(e.target.closest('#zoom'))return;
314
- if(!curId)return; e.preventDefault(); try{stage.setPointerCapture(e.pointerId)}catch{}
315
- tip.classList.add('fade');
316
- // 自愈:primary 指针按下 = 浏览器认为此刻没有别的触点(触摸序列的第一指 / 鼠标)⇒ 之前丢了 up / cancel 留下的幽灵指针一律清掉,否则手势态卡死、一指点什么都不发
317
- if(e.isPrimary){ if(down&&lastP) push({t:'up',...lastP}); down=false; if(pend){clearTimeout(pend.timer);pend=null} drag=null; ptrs.clear(); gesture=null; pan=null } // 上一次的 down 没等到 up ⇒ 先把远端鼠标松开,不然页面那边一直按着
318
- ptrs.set(e.pointerId,{x:e.clientX,y:e.clientY});
319
- if(ptrs.size===2){ // 进入两指手势:没发出去的 down 作废;已发出去的补一个 up(页面会看到一次轻点,少见)
320
- if(pend){clearTimeout(pend.timer);pend=null}
321
- if(down){ down=false; if(moveTimer){clearTimeout(moveTimer);moveTimer=null} pendingMoves=[]; if(lastP)push({t:'up',...lastP}) }
322
- drag=null; pan=null;
323
- const [a,b]=[...ptrs.values()]; gesture={d:Math.hypot(a.x-b.x,a.y-b.y),cx:(a.x+b.x)/2,cy:(a.y+b.y)/2,zoom,tx,ty}; return;
324
- }
325
- if(ptrs.size>2||gesture)return;
326
- if(mode==='pan'){ pan={x:e.clientX,y:e.clientY,tx,ty}; return }
327
- const p=map(e); if(!p)return;
328
- if(mode==='scroll'){ drag={y:e.clientY,p}; return }
329
- pend={p,x0:e.clientX,y0:e.clientY,timer:setTimeout(commitDown,120)};
330
- });
331
- stage.addEventListener('pointermove',e=>{
332
- if(!curId)return;
333
- if(ptrs.has(e.pointerId)) ptrs.set(e.pointerId,{x:e.clientX,y:e.clientY});
334
- if(gesture){
335
- if(ptrs.size<2)return; const [a,b]=[...ptrs.values()]; const d=Math.hypot(a.x-b.x,a.y-b.y),cx=(a.x+b.x)/2,cy=(a.y+b.y)/2; const r=stage.getBoundingClientRect();
336
- const z=Math.min(5,Math.max(1,gesture.zoom*d/gesture.d)),k=z/gesture.zoom; const ax=gesture.cx-r.left,ay=gesture.cy-r.top;
337
- tx=ax-(ax-gesture.tx)*k+(cx-gesture.cx); ty=ay-(ay-gesture.ty)*k+(cy-gesture.cy); zoom=z; applyView(); return; // 缩放锚在起始中点 + 中点位移 = 移动
338
- }
339
- if(pan){ tx=pan.tx+(e.clientX-pan.x); ty=pan.ty+(e.clientY-pan.y); applyView(); return }
340
- if(drag){ const r=img.getBoundingClientRect(); const dy=(drag.y-e.clientY)/r.height*meta.h; if(Math.abs(dy)>=4){ drag.y=e.clientY; push({t:'wheel',...drag.p,dy:Math.round(dy)}) } return }
341
- if(pend){ if(Math.hypot(e.clientX-pend.x0,e.clientY-pend.y0)<6)return; commitDown() }
342
- if(!down)return; const p=map(e); if(!p)return; lastP=p;
343
- pendingMoves.push({t:'move',...p,down:true});
344
- if(pendingMoves.length>MOVE_KEEP) pendingMoves=pendingMoves.filter((_,i,a)=>i%2===1||i===a.length-1); // 隔一个抽一个,最后一个点必留
345
- if(!moveTimer) moveTimer=setTimeout(()=>{moveTimer=null;flush()},33); // 33ms ≈ 30 次/秒:拖滑块够顺,又不至于每次指针抖动都打一个请求
346
- });
347
- const endPtr=(e)=>{
348
- ptrs.delete(e.pointerId);
349
- if(gesture){ if(ptrs.size===0)gesture=null; return } // 手势里先松一指:等全松开才回到正常,剩下那指不算页面操作
350
- if(pan){ if(ptrs.size===0)pan=null; return }
351
- if(!curId)return;
352
- if(drag){drag=null;return}
353
- if(pend) commitDown(); // 轻点:down 还没发就松开了 ⇒ 先补 down 再 up
354
- if(!down)return; down=false; const p=map(e); if(moveTimer){clearTimeout(moveTimer);moveTimer=null} takeMoves(); if(p) push({t:'up',...p}); else flush()
355
- };
356
- stage.addEventListener('pointerup',endPtr); stage.addEventListener('pointercancel',endPtr);
357
- stage.addEventListener('wheel',e=>{ if(!curId)return; e.preventDefault();
358
- if(e.ctrlKey||mode==='pan'){ const r=stage.getBoundingClientRect(); zoomAt(e.deltaY<0?1.15:1/1.15,e.clientX-r.left,e.clientY-r.top); return } // 触控板捏合到浏览器 = ctrl+wheel
359
- const p=map(e); if(!p)return; push({t:'wheel',...p,dx:Math.round(e.deltaX),dy:Math.round(e.deltaY)}) },{passive:false});
360
- /* 键盘 */
361
- const KEYS=new Set(['Enter','Backspace','Tab','Escape','Delete','ArrowUp','ArrowDown','ArrowLeft','ArrowRight','Home','End','PageUp','PageDown']);
362
- const sendText=()=>{ const s=txt.value; if(!s)return false; push({t:'text',text:s}); txt.value=''; return true };
363
- $('#send').onclick=()=>{ sendText(); txt.focus() };
364
- txt.addEventListener('keydown',e=>{ if(e.key==='Enter'){ e.preventDefault(); sendText(); push({t:'key',key:'Enter'}) } });
365
- for(const b of document.querySelectorAll('.keys button')) b.onclick=()=>{ push({t:'key',key:b.dataset.key}); };
366
- document.addEventListener('keydown',e=>{
367
- if(!curId||e.target===txt||e.target.tagName==='SELECT'||e.target.tagName==='INPUT')return;
368
- if(KEYS.has(e.key)){ e.preventDefault(); push({t:'key',key:e.key,shift:e.shiftKey,ctrl:e.ctrlKey,alt:e.altKey,meta:e.metaKey}) }
369
- else if(e.key.length===1&&!e.ctrlKey&&!e.metaKey){ e.preventDefault(); push({t:'text',text:e.key}) }
370
- });
371
-
372
- /* ── 接管链接(管理员)/ 结束接管(群成员) ── */
373
- const mask=$('#mask'),card=$('#card');
374
- const closeCard=()=>{mask.hidden=true};
375
- mask.addEventListener('click',e=>{ if(e.target===mask) closeCard() });
376
- $('#mklink').onclick=async()=>{
377
- try{
378
- const r=await api('/api/browser/link','POST',{});
379
- card.innerHTML=`<h3>接管链接</h3><div class="lnk" id="lnk"></div><div class="hint2">发给需要登录的人。${r.minutes} 分钟内有效,<b>只能打开一次</b>;打开后在那边的页面上点、拖、输入,登录态会留在机器人的浏览器里。${r.reach==='public'?'':'<br>⚠ 这是局域网地址,出了局域网打不开(要给外面的人用,先在控制台设置里填公网地址)。'}</div><div class="row"><button id="cp" class="primary">复制</button><button id="cl">关闭</button></div>`;
380
- $('#lnk').textContent=r.url; mask.hidden=false;
381
- $('#cp').onclick=async()=>{ try{ await navigator.clipboard.writeText(r.url); $('#cp').textContent='已复制' }catch{ $('#cp').textContent='长按上面的地址复制' } };
382
- $('#cl').onclick=closeCard;
383
- }catch(e){ setSt(e.message,'bad') }
384
- };
385
- $('#end').onclick=async()=>{
386
- try{ await api('/api/browser/end','POST',{}) }catch{}
387
- stopStream(); panel({icon:'check',title:'已结束接管',text:'这条链接不能再用了。要再来一次,回群里让机器人重新发一条。'});
388
- };
389
- if(ADMIN||TAKEOVER) loadTargets();
390
- </script>
391
- </body>
392
- </html>