@foxden-app/foxclaw 0.5.78 → 0.6.3
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/.env.example +11 -0
- package/CHANGELOG.md +38 -0
- package/README.md +17 -1
- package/README_EN.md +17 -1
- package/dist/codex_app/client.d.ts +1 -0
- package/dist/codex_app/client.js +62 -11
- package/dist/config.d.ts +8 -0
- package/dist/config.js +18 -0
- package/dist/i18n.d.ts +4 -0
- package/dist/i18n.js +37 -0
- package/dist/main.js +20 -1
- package/dist/opencode/client.d.ts +66 -0
- package/dist/opencode/client.js +499 -0
- package/dist/opencode/controller.d.ts +137 -0
- package/dist/opencode/controller.js +2009 -0
- package/dist/opencode/events.d.ts +61 -0
- package/dist/opencode/events.js +145 -0
- package/dist/opencode/runtime.d.ts +14 -0
- package/dist/opencode/runtime.js +29 -0
- package/dist/store/database.d.ts +1 -0
- package/dist/store/database.js +9 -0
- package/dist/telegram/gateway.d.ts +6 -1
- package/dist/telegram/gateway.js +6 -4
- package/package.json +3 -2
|
@@ -0,0 +1,2009 @@
|
|
|
1
|
+
import fs from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { pathToFileURL } from 'node:url';
|
|
4
|
+
import { TELEGRAM_VOICE_MAX_BYTES, TELEGRAM_VOICE_SUPPORTED_EXTENSIONS, telegramVoiceContentType, } from '../voice/files.js';
|
|
5
|
+
import { synthesizeTelegramVoice } from '../voice/tts.js';
|
|
6
|
+
import { parseCommand } from '../controller/commands.js';
|
|
7
|
+
import { normalizeLocale } from '../i18n.js';
|
|
8
|
+
import { isDefaultTelegramScope, resolveTelegramAddressing } from '../telegram/addressing.js';
|
|
9
|
+
import { buildAttachmentPrompt, isNativeImageAttachment, planAttachmentStoragePath, TELEGRAM_BOT_API_DOWNLOAD_LIMIT_BYTES, } from '../telegram/media.js';
|
|
10
|
+
import { chunkTelegramMessage, chunkTelegramStreamMessage } from '../telegram/text.js';
|
|
11
|
+
import { formatSdkError } from './client.js';
|
|
12
|
+
const STREAM_THROTTLE_MS = 700;
|
|
13
|
+
const TOOL_THROTTLE_MS = 600;
|
|
14
|
+
const THREAD_LIST_LIMIT = 10;
|
|
15
|
+
const SETUP_CALLBACK_PREFIX = 'oc:s:';
|
|
16
|
+
const PERMISSION_CALLBACK_PREFIX = 'oc:p:';
|
|
17
|
+
const QUESTION_CALLBACK_PREFIX = 'oc:q:';
|
|
18
|
+
const UNSUPPORTED_COMMANDS = new Set([
|
|
19
|
+
'account', 'auth_reload', 'codex_restart',
|
|
20
|
+
'goal', 'goal_clear', 'goal_done', 'goal_pause', 'goal_resume', 'login',
|
|
21
|
+
'login_cancel', 'login_device', 'logout', 'plugin', 'plugin_skill', 'quota',
|
|
22
|
+
'remote', 'requirements', 'service_tier', 'update',
|
|
23
|
+
]);
|
|
24
|
+
/** Telegram-facing OpenCode runtime. It shares FoxClaw's gateway/store/rendering primitives. */
|
|
25
|
+
export class OpencodeBridgeCore {
|
|
26
|
+
config;
|
|
27
|
+
store;
|
|
28
|
+
logger;
|
|
29
|
+
bot;
|
|
30
|
+
app;
|
|
31
|
+
messaging;
|
|
32
|
+
activeTurns = new Map();
|
|
33
|
+
watchers = new Map();
|
|
34
|
+
queuedPrompts = new Map();
|
|
35
|
+
permissions = new Map();
|
|
36
|
+
questions = new Map();
|
|
37
|
+
setupActions = new Map();
|
|
38
|
+
latestVoiceText = new Map();
|
|
39
|
+
locks = new Map();
|
|
40
|
+
finishingSessions = new Map();
|
|
41
|
+
handlingPermissionIds = new Set();
|
|
42
|
+
handlingQuestionIds = new Set();
|
|
43
|
+
disconnectCleanup = null;
|
|
44
|
+
started = false;
|
|
45
|
+
constructor(config, store, logger, bot, app, messaging) {
|
|
46
|
+
this.config = config;
|
|
47
|
+
this.store = store;
|
|
48
|
+
this.logger = logger;
|
|
49
|
+
this.bot = bot;
|
|
50
|
+
this.app = app;
|
|
51
|
+
this.messaging = messaging;
|
|
52
|
+
}
|
|
53
|
+
registerInboundHandlers() {
|
|
54
|
+
this.bot.on('text', (event) => {
|
|
55
|
+
void this.withLock(event.scopeId, () => this.handleText(event)).catch((error) => this.reportError(event.scopeId, error));
|
|
56
|
+
});
|
|
57
|
+
this.bot.on('callback', (event) => {
|
|
58
|
+
void this.withLock(event.scopeId, () => this.handleCallback(event)).catch((error) => this.reportError(event.scopeId, error));
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
async start() {
|
|
62
|
+
if (this.started)
|
|
63
|
+
return;
|
|
64
|
+
this.started = true;
|
|
65
|
+
this.app.on('event', (event) => {
|
|
66
|
+
void this.handleAppEvent(event).catch((error) => this.reportError(null, error));
|
|
67
|
+
});
|
|
68
|
+
this.app.on('disconnected', (detail) => {
|
|
69
|
+
this.logger.warn('opencode.disconnected', detail);
|
|
70
|
+
this.disconnectCleanup = this.cleanupAfterDisconnect();
|
|
71
|
+
});
|
|
72
|
+
this.app.on('connected', () => {
|
|
73
|
+
const cleanup = this.disconnectCleanup;
|
|
74
|
+
if (!cleanup)
|
|
75
|
+
return;
|
|
76
|
+
this.disconnectCleanup = null;
|
|
77
|
+
void this.recoverAfterReconnect(cleanup).catch((error) => this.reportError(null, error));
|
|
78
|
+
});
|
|
79
|
+
try {
|
|
80
|
+
await this.app.start();
|
|
81
|
+
await this.app.recoverPendingRequests(this.store.listBindings()
|
|
82
|
+
.filter((binding) => this.isOwnScope(binding.chatId))
|
|
83
|
+
.flatMap((binding) => binding.cwd ? [binding.cwd] : []));
|
|
84
|
+
await this.bot.start();
|
|
85
|
+
}
|
|
86
|
+
catch (error) {
|
|
87
|
+
await this.stop();
|
|
88
|
+
throw error;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
async stop() {
|
|
92
|
+
if (!this.started)
|
|
93
|
+
return;
|
|
94
|
+
this.bot.stop();
|
|
95
|
+
for (const turn of this.activeTurns.values())
|
|
96
|
+
this.clearTurnTimers(turn);
|
|
97
|
+
for (const watch of this.watchers.values()) {
|
|
98
|
+
if (watch.flushTimer)
|
|
99
|
+
clearTimeout(watch.flushTimer);
|
|
100
|
+
}
|
|
101
|
+
this.activeTurns.clear();
|
|
102
|
+
this.watchers.clear();
|
|
103
|
+
this.queuedPrompts.clear();
|
|
104
|
+
this.setupActions.clear();
|
|
105
|
+
this.latestVoiceText.clear();
|
|
106
|
+
this.finishingSessions.clear();
|
|
107
|
+
this.handlingPermissionIds.clear();
|
|
108
|
+
this.handlingQuestionIds.clear();
|
|
109
|
+
this.disconnectCleanup = null;
|
|
110
|
+
await this.app.stop({ terminateServer: true });
|
|
111
|
+
this.started = false;
|
|
112
|
+
}
|
|
113
|
+
get isRunning() {
|
|
114
|
+
return this.started;
|
|
115
|
+
}
|
|
116
|
+
get activeTurnCount() {
|
|
117
|
+
return this.activeTurns.size;
|
|
118
|
+
}
|
|
119
|
+
getRuntimeStatus() {
|
|
120
|
+
return {
|
|
121
|
+
connected: this.app.isConnected(),
|
|
122
|
+
activeTurns: this.activeTurns.size,
|
|
123
|
+
botUsername: this.bot.username,
|
|
124
|
+
server: this.app.getServerStatus(),
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
withLock(scopeId, task) {
|
|
128
|
+
const previous = this.locks.get(scopeId) ?? Promise.resolve();
|
|
129
|
+
const next = previous.then(task, task);
|
|
130
|
+
this.locks.set(scopeId, next.then(() => undefined, () => undefined));
|
|
131
|
+
return next;
|
|
132
|
+
}
|
|
133
|
+
async reportError(scopeId, error) {
|
|
134
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
135
|
+
this.logger.error('opencode.bridge_error', { scopeId, error: message });
|
|
136
|
+
if (scopeId)
|
|
137
|
+
await this.send(scopeId, `⚠️ ${message}`).catch(() => { });
|
|
138
|
+
}
|
|
139
|
+
unsupportedCommandMessage(name, locale) {
|
|
140
|
+
if (name === 'update')
|
|
141
|
+
return localize(locale, '/update 当前只由 Codex runtime 的升级协调器执行,避免双 Bot 同时重启服务。请在 Codex Bot 使用 /update,或在终端运行 foxclaw update。', '/update is currently owned by the Codex runtime update coordinator to prevent both bots restarting the service. Use /update in the Codex bot or run foxclaw update in a terminal.');
|
|
142
|
+
if (['account', 'quota', 'login', 'login_cancel', 'login_device', 'logout', 'auth_reload', 'codex_restart'].includes(name)) {
|
|
143
|
+
return localize(locale, `/${name} 依赖 Codex 账户、配额或设备登录协议;OpenCode serve 没有对应 API。Provider 状态可用 /auth 查看,登录与切换请在终端运行 opencode auth。`, `/${name} depends on Codex account, quota, or device-login protocols, which OpenCode serve does not expose. Use /auth for provider status and opencode auth in a terminal to sign in or switch.`);
|
|
144
|
+
}
|
|
145
|
+
if (name.startsWith('goal'))
|
|
146
|
+
return localize(locale, `/${name} 依赖 Codex 的持久 Goal 原语;OpenCode session 没有等价状态机。`, `/${name} depends on Codex's persistent Goal primitive; OpenCode sessions have no equivalent state machine.`);
|
|
147
|
+
if (name === 'remote')
|
|
148
|
+
return localize(locale, '/remote 是 Codex Remote 会话协议,OpenCode serve 没有等价端点。', '/remote is a Codex Remote session protocol; OpenCode serve has no equivalent endpoint.');
|
|
149
|
+
return localize(locale, `/${name} 在 OpenCode serve 上没有可保持语义的等价 API。`, `/${name} has no semantics-preserving equivalent in OpenCode serve.`);
|
|
150
|
+
}
|
|
151
|
+
localeForScope(scopeId, languageCode) {
|
|
152
|
+
const detected = normalizeLocale(languageCode);
|
|
153
|
+
const current = this.store.getChatSettings(scopeId)?.locale;
|
|
154
|
+
if (languageCode && current !== detected)
|
|
155
|
+
this.store.setChatLocale(scopeId, detected);
|
|
156
|
+
return languageCode ? detected : current ?? 'zh';
|
|
157
|
+
}
|
|
158
|
+
async handleText(event) {
|
|
159
|
+
const command = event.attachments.length === 0 ? parseCommand(event.text) : null;
|
|
160
|
+
const decision = resolveTelegramAddressing({
|
|
161
|
+
text: event.text,
|
|
162
|
+
attachmentsCount: event.attachments.length,
|
|
163
|
+
entities: event.entities,
|
|
164
|
+
command,
|
|
165
|
+
botUsername: this.bot.username,
|
|
166
|
+
isDefaultTopic: isDefaultTelegramScope({
|
|
167
|
+
chatType: event.chatType,
|
|
168
|
+
allowedChatId: this.config.tgAllowedChatId,
|
|
169
|
+
allowedTopicId: this.config.tgAllowedTopicId,
|
|
170
|
+
topicId: event.topicId,
|
|
171
|
+
requireExplicitGroupAddressing: this.config.tgRequireExplicitGroupAddressing,
|
|
172
|
+
}),
|
|
173
|
+
replyToBot: event.replyToBot,
|
|
174
|
+
});
|
|
175
|
+
if (decision.kind === 'ignore')
|
|
176
|
+
return;
|
|
177
|
+
const locale = this.localeForScope(event.scopeId, event.languageCode);
|
|
178
|
+
if (decision.kind === 'command') {
|
|
179
|
+
await this.handleCommand(event, decision.command.name, decision.command.args, locale);
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
await this.dispatchPrompt(event, decision.text, locale);
|
|
183
|
+
}
|
|
184
|
+
async handleCommand(event, name, args, locale) {
|
|
185
|
+
const scopeId = event.scopeId;
|
|
186
|
+
if (UNSUPPORTED_COMMANDS.has(name)) {
|
|
187
|
+
await this.send(scopeId, this.unsupportedCommandMessage(name, locale));
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
190
|
+
switch (name) {
|
|
191
|
+
case 'start':
|
|
192
|
+
case 'help':
|
|
193
|
+
await this.showHelp(scopeId, locale);
|
|
194
|
+
return;
|
|
195
|
+
case 'new':
|
|
196
|
+
await this.createAndBind(scopeId, args.join(' ').trim() || null, locale);
|
|
197
|
+
return;
|
|
198
|
+
case 'threads':
|
|
199
|
+
await this.showThreads(scopeId, args.join(' ').trim(), locale);
|
|
200
|
+
return;
|
|
201
|
+
case 'open':
|
|
202
|
+
await this.openSession(scopeId, args[0] ?? '', locale);
|
|
203
|
+
return;
|
|
204
|
+
case 'watch':
|
|
205
|
+
await this.watchSession(scopeId, args[0] ?? '', locale);
|
|
206
|
+
return;
|
|
207
|
+
case 'unwatch':
|
|
208
|
+
await this.unwatchSession(scopeId, locale);
|
|
209
|
+
return;
|
|
210
|
+
case 'status':
|
|
211
|
+
await this.showStatus(scopeId, locale);
|
|
212
|
+
return;
|
|
213
|
+
case 'setup':
|
|
214
|
+
await this.showSetup(scopeId, locale);
|
|
215
|
+
return;
|
|
216
|
+
case 'models':
|
|
217
|
+
await this.showModels(scopeId, args.join(' ').trim(), locale);
|
|
218
|
+
return;
|
|
219
|
+
case 'model':
|
|
220
|
+
await this.setModel(scopeId, args.join(' ').trim(), locale);
|
|
221
|
+
return;
|
|
222
|
+
case 'effort':
|
|
223
|
+
await this.setVariant(scopeId, args.join(' ').trim(), locale);
|
|
224
|
+
return;
|
|
225
|
+
case 'mode':
|
|
226
|
+
await this.setMode(scopeId, args[0] ?? '', locale);
|
|
227
|
+
return;
|
|
228
|
+
case 'plan':
|
|
229
|
+
await this.setMode(scopeId, 'plan', locale);
|
|
230
|
+
return;
|
|
231
|
+
case 'agent':
|
|
232
|
+
await this.setAgent(scopeId, args.join(' ').trim(), locale);
|
|
233
|
+
return;
|
|
234
|
+
case 'permissions':
|
|
235
|
+
case 'access':
|
|
236
|
+
await this.setAccess(scopeId, args.join(' ').trim(), locale);
|
|
237
|
+
return;
|
|
238
|
+
case 'active':
|
|
239
|
+
await this.setActiveMode(scopeId, args[0] ?? '', locale);
|
|
240
|
+
return;
|
|
241
|
+
case 'steer':
|
|
242
|
+
await this.sendWithBehavior(event, args.join(' ').trim(), locale, 'steer');
|
|
243
|
+
return;
|
|
244
|
+
case 'queue':
|
|
245
|
+
await this.sendWithBehavior(event, args.join(' ').trim(), locale, 'queue');
|
|
246
|
+
return;
|
|
247
|
+
case 'takeover':
|
|
248
|
+
await this.takeOver(event, args.join(' ').trim(), locale);
|
|
249
|
+
return;
|
|
250
|
+
case 'history':
|
|
251
|
+
await this.showHistory(scopeId, args[0] ?? '', locale);
|
|
252
|
+
return;
|
|
253
|
+
case 'rename':
|
|
254
|
+
await this.renameSession(scopeId, args.join(' ').trim(), locale);
|
|
255
|
+
return;
|
|
256
|
+
case 'fork':
|
|
257
|
+
await this.forkSession(scopeId, args.join(' ').trim(), locale);
|
|
258
|
+
return;
|
|
259
|
+
case 'undo':
|
|
260
|
+
case 'rollback':
|
|
261
|
+
await this.undoSession(scopeId, args[0] ?? '', locale);
|
|
262
|
+
return;
|
|
263
|
+
case 'redo':
|
|
264
|
+
await this.redoSession(scopeId, locale);
|
|
265
|
+
return;
|
|
266
|
+
case 'diff':
|
|
267
|
+
await this.showDiff(scopeId, locale);
|
|
268
|
+
return;
|
|
269
|
+
case 'where':
|
|
270
|
+
await this.showWhere(scopeId, locale);
|
|
271
|
+
return;
|
|
272
|
+
case 'reveal':
|
|
273
|
+
await this.showWhere(scopeId, locale);
|
|
274
|
+
return;
|
|
275
|
+
case 'files':
|
|
276
|
+
await this.findFiles(scopeId, args.join(' ').trim(), locale);
|
|
277
|
+
return;
|
|
278
|
+
case 'compact':
|
|
279
|
+
await this.compactSession(scopeId, locale);
|
|
280
|
+
return;
|
|
281
|
+
case 'loaded':
|
|
282
|
+
await this.showLoaded(scopeId, locale);
|
|
283
|
+
return;
|
|
284
|
+
case 'skills':
|
|
285
|
+
await this.showSkills(scopeId, locale);
|
|
286
|
+
return;
|
|
287
|
+
case 'mcp':
|
|
288
|
+
await this.showMcp(scopeId, locale);
|
|
289
|
+
return;
|
|
290
|
+
case 'apps':
|
|
291
|
+
await this.showMcp(scopeId, locale, true);
|
|
292
|
+
return;
|
|
293
|
+
case 'provider':
|
|
294
|
+
await this.showProviders(scopeId, locale);
|
|
295
|
+
return;
|
|
296
|
+
case 'auth':
|
|
297
|
+
await this.showAuth(scopeId, locale);
|
|
298
|
+
return;
|
|
299
|
+
case 'plugins':
|
|
300
|
+
await this.showPlugins(scopeId, locale, false);
|
|
301
|
+
return;
|
|
302
|
+
case 'hooks':
|
|
303
|
+
await this.showPlugins(scopeId, locale, true);
|
|
304
|
+
return;
|
|
305
|
+
case 'features':
|
|
306
|
+
await this.showFeatures(scopeId, locale);
|
|
307
|
+
return;
|
|
308
|
+
case 'config':
|
|
309
|
+
await this.showConfig(scopeId, locale);
|
|
310
|
+
return;
|
|
311
|
+
case 'archive':
|
|
312
|
+
await this.archiveBoundSession(scopeId, locale);
|
|
313
|
+
return;
|
|
314
|
+
case 'unarchive':
|
|
315
|
+
case 'thread_unarchive':
|
|
316
|
+
await this.unarchiveSession(scopeId, args[0] ?? '', locale);
|
|
317
|
+
return;
|
|
318
|
+
case 'thread_archive':
|
|
319
|
+
await this.archiveCachedSession(scopeId, args[0] ?? '', locale);
|
|
320
|
+
return;
|
|
321
|
+
case 'review':
|
|
322
|
+
await this.runReview(event, args.join(' ').trim(), locale);
|
|
323
|
+
return;
|
|
324
|
+
case 'rich':
|
|
325
|
+
await this.showRichDemo(scopeId, locale);
|
|
326
|
+
return;
|
|
327
|
+
case 'voice':
|
|
328
|
+
await this.handleVoiceCommand(scopeId, args, locale);
|
|
329
|
+
return;
|
|
330
|
+
case 'fast':
|
|
331
|
+
await this.showFastUnsupported(scopeId, locale);
|
|
332
|
+
return;
|
|
333
|
+
case 'approve':
|
|
334
|
+
await this.approveFromCommand(scopeId, args, locale);
|
|
335
|
+
return;
|
|
336
|
+
case 'answer':
|
|
337
|
+
await this.answerFromCommand(scopeId, args, locale);
|
|
338
|
+
return;
|
|
339
|
+
case 'abort':
|
|
340
|
+
case 'interrupt':
|
|
341
|
+
case 'stop':
|
|
342
|
+
await this.abort(scopeId, locale);
|
|
343
|
+
return;
|
|
344
|
+
default:
|
|
345
|
+
await this.send(scopeId, localize(locale, `未知命令:/${name}。发送 /help 查看列表。`, `Unknown command: /${name}. Send /help.`));
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
async showHelp(scopeId, locale) {
|
|
349
|
+
const zh = [
|
|
350
|
+
'⚡ OpenCode 桥接命令', '',
|
|
351
|
+
'/new [目录] · 新建会话', '/threads [关键词|archived] · 最近或归档会话', '/open <编号|ID> · 打开会话',
|
|
352
|
+
'/watch [编号|ID] · 观察会话', '/unwatch · 停止观察', '/setup · 设置面板',
|
|
353
|
+
'/models [provider] · Provider → Model 两层选择', '/model <provider/model|default> · 选择模型', '/effort <variant|default> · 推理档位',
|
|
354
|
+
'/fast · 说明 OpenCode 与 Codex Fast 的能力差异', '/mode <default|plan> · 模式', '/plan · 下一轮 Plan', '/agent [名称] · Agent',
|
|
355
|
+
'/permissions <read-only|default|full-access> · 权限', '/active <steer|queue> · 运行中新消息',
|
|
356
|
+
'/steer <消息> · 引导当前回复', '/queue <消息> · 排队下一轮', '/takeover <消息> · 中断并接管',
|
|
357
|
+
'/history [数量] · 历史', '/rename <名称> · 重命名', '/fork [名称] · 分叉', '/undo [数量] · 原生回退', '/redo · 原生重做',
|
|
358
|
+
'/review [commit|branch|pr] · 原生代码审查', '/archive · 归档当前会话', '/unarchive <编号> · 恢复归档会话', '/diff · 变更',
|
|
359
|
+
'/where · 当前目录', '/files <关键词> · 文件搜索', '/compact · 压缩上下文', '/loaded · 活跃会话',
|
|
360
|
+
'/skills · Skills', '/mcp · MCP 状态', '/apps · MCP 应用', '/provider · Provider', '/auth · Provider 认证状态',
|
|
361
|
+
'/plugins · Plugins', '/hooks · Plugin hooks', '/features · 实验能力', '/config · 配置摘要',
|
|
362
|
+
'/approve · 待审批', '/answer · 待回答', '/rich · 富文本测试', '/voice <文本|last|file> · 语音', '/interrupt · 中断', '/status · 状态', '',
|
|
363
|
+
'直接发送文本、图片或文件会继续当前会话;没有绑定时会自动新建。',
|
|
364
|
+
'Codex 账户、配额、Goal、Remote 没有 OpenCode serve 等价 API;Provider 登录请在终端运行 opencode auth。',
|
|
365
|
+
].join('\n');
|
|
366
|
+
const en = [
|
|
367
|
+
'⚡ OpenCode bridge commands', '',
|
|
368
|
+
'/new [dir] · new session', '/threads [query|archived] · recent or archived sessions', '/open <number|ID> · bind session',
|
|
369
|
+
'/watch [number|ID] · watch session', '/unwatch · stop watching', '/setup · settings panel',
|
|
370
|
+
'/models [provider] · Provider → Model selector', '/model <provider/model|default> · select model', '/effort <variant|default> · reasoning variant',
|
|
371
|
+
'/fast · explain the OpenCode/Codex Fast capability difference', '/mode <default|plan> · mode', '/plan · Plan next turn', '/agent [name] · agent',
|
|
372
|
+
'/permissions <read-only|default|full-access> · access', '/active <steer|queue> · messages during a turn',
|
|
373
|
+
'/steer <message> · steer active turn', '/queue <message> · queue next turn', '/takeover <message> · interrupt and take over',
|
|
374
|
+
'/history [n] · history', '/rename <name> · rename', '/fork [name] · fork', '/undo [n] · native undo', '/redo · native redo',
|
|
375
|
+
'/review [commit|branch|pr] · native review', '/archive · archive current session', '/unarchive <number> · restore archive', '/diff · changes',
|
|
376
|
+
'/where · directory', '/files <query> · find files', '/compact · compact context', '/loaded · active sessions',
|
|
377
|
+
'/skills · skills', '/mcp · MCP status', '/apps · MCP apps', '/provider · providers', '/auth · provider auth status',
|
|
378
|
+
'/plugins · plugins', '/hooks · plugin hooks', '/features · experimental capabilities', '/config · config summary',
|
|
379
|
+
'/approve · approvals', '/answer · questions', '/rich · rich-text test', '/voice <text|last|file> · voice', '/interrupt · abort', '/status · status', '',
|
|
380
|
+
'Plain text, images, and files continue the bound session, creating one when needed.',
|
|
381
|
+
'Codex account, quota, Goal, and Remote have no OpenCode serve API equivalents; run opencode auth in a terminal for provider login.',
|
|
382
|
+
].join('\n');
|
|
383
|
+
await this.send(scopeId, localize(locale, zh, en));
|
|
384
|
+
}
|
|
385
|
+
async createAndBind(scopeId, requestedCwd, locale) {
|
|
386
|
+
const cwd = requestedCwd ? path.resolve(requestedCwd) : this.config.defaultCwd;
|
|
387
|
+
const stat = await fs.stat(cwd).catch(() => null);
|
|
388
|
+
if (!stat?.isDirectory())
|
|
389
|
+
throw new Error(localize(locale, `目录不存在:${cwd}`, `Directory does not exist: ${cwd}`));
|
|
390
|
+
const settings = this.store.getChatSettings(scopeId);
|
|
391
|
+
const model = settings?.model ? parseStoredModel(settings.model) : null;
|
|
392
|
+
const prefs = readPrefs(settings);
|
|
393
|
+
const agent = prefs.agent;
|
|
394
|
+
const response = await this.app.getClient().session.create({
|
|
395
|
+
directory: cwd,
|
|
396
|
+
...(model ? { model: { id: model.modelId, providerID: model.providerId, ...(prefs.variant ? { variant: prefs.variant } : {}) } } : {}),
|
|
397
|
+
...(agent ? { agent } : {}),
|
|
398
|
+
permission: permissionRules(settings?.accessPreset ?? 'default'),
|
|
399
|
+
});
|
|
400
|
+
const session = unwrap(response, 'session.create');
|
|
401
|
+
this.store.setBinding(scopeId, session.id, session.directory || cwd);
|
|
402
|
+
await this.send(scopeId, localize(locale, `✅ 已新建 OpenCode 会话\n${session.title}\n\`${session.id}\`\n目录:\`${session.directory || cwd}\``, `✅ OpenCode session created\n${session.title}\n\`${session.id}\`\nDirectory: \`${session.directory || cwd}\``));
|
|
403
|
+
return session;
|
|
404
|
+
}
|
|
405
|
+
async showThreads(scopeId, search, locale) {
|
|
406
|
+
const archived = /^archived(?:\s|$)/i.test(search);
|
|
407
|
+
const query = archived ? search.replace(/^archived\s*/i, '').trim() : search;
|
|
408
|
+
const response = await this.app.getClient().experimental.session.list({
|
|
409
|
+
...(query ? { search: query } : {}),
|
|
410
|
+
...(archived ? { archived: true } : {}),
|
|
411
|
+
limit: Math.max(THREAD_LIST_LIMIT, this.config.threadListLimit),
|
|
412
|
+
});
|
|
413
|
+
const sessions = unwrap(response, 'session.list').filter((session) => archived ? Boolean(session.time.archived) : !session.time.archived);
|
|
414
|
+
if (sessions.length === 0) {
|
|
415
|
+
await this.send(scopeId, localize(locale, '没有匹配的 OpenCode 会话。', 'No matching OpenCode sessions.'));
|
|
416
|
+
return;
|
|
417
|
+
}
|
|
418
|
+
const statuses = await this.statusesForSessions(sessions);
|
|
419
|
+
this.store.cacheThreadList(scopeId, sessions.map((session) => ({
|
|
420
|
+
threadId: session.id,
|
|
421
|
+
name: session.title || null,
|
|
422
|
+
preview: session.title || session.slug,
|
|
423
|
+
cwd: session.directory,
|
|
424
|
+
modelProvider: session.model ? `${session.model.providerID}/${session.model.id}` : null,
|
|
425
|
+
status: statuses[session.id]?.type === 'busy' ? 'active' : 'idle',
|
|
426
|
+
archived,
|
|
427
|
+
updatedAt: session.time.updated,
|
|
428
|
+
})));
|
|
429
|
+
const bound = this.store.getBinding(scopeId)?.threadId;
|
|
430
|
+
const lines = [archived ? localize(locale, 'OpenCode 已归档会话:', 'Archived OpenCode sessions:') : localize(locale, 'OpenCode 会话:', 'OpenCode sessions:'), ''];
|
|
431
|
+
sessions.forEach((session, index) => {
|
|
432
|
+
const marker = session.id === bound ? '●' : statuses[session.id]?.type === 'busy' ? '◐' : '○';
|
|
433
|
+
lines.push(`${marker} ${index + 1}. ${session.title || session.slug}`);
|
|
434
|
+
lines.push(` \`${shortId(session.id)}\` · \`${session.directory}\` · ${formatAge(session.time.updated, locale)}`);
|
|
435
|
+
});
|
|
436
|
+
lines.push('', archived
|
|
437
|
+
? localize(locale, '使用 /unarchive <编号> 恢复。', 'Use /unarchive <number> to restore.')
|
|
438
|
+
: localize(locale, '使用 /open <编号> 打开;/threads archived 查看归档。', 'Use /open <number> to bind; /threads archived lists archives.'));
|
|
439
|
+
const keyboard = archived ? undefined : this.setupKeyboard(sessions.slice(0, 10).map((session, index) => [
|
|
440
|
+
{ label: `${index + 1}. ${clip(session.title || session.slug, 30)}`, action: { scopeId, kind: 'open', value: session.id } },
|
|
441
|
+
{ label: '👁', action: { scopeId, kind: 'watch', value: session.id } },
|
|
442
|
+
]));
|
|
443
|
+
await this.messaging.sendPlain(scopeId, lines.join('\n'), keyboard);
|
|
444
|
+
}
|
|
445
|
+
async resolveSessionTarget(scopeId, raw) {
|
|
446
|
+
const value = raw.trim();
|
|
447
|
+
if (!value) {
|
|
448
|
+
const binding = this.store.getBinding(scopeId);
|
|
449
|
+
if (!binding)
|
|
450
|
+
return null;
|
|
451
|
+
const response = await this.app.getClient().session.get({
|
|
452
|
+
sessionID: binding.threadId,
|
|
453
|
+
...(binding.cwd ? { directory: binding.cwd } : {}),
|
|
454
|
+
});
|
|
455
|
+
return response.error ? null : response.data ?? null;
|
|
456
|
+
}
|
|
457
|
+
const index = Number.parseInt(value, 10);
|
|
458
|
+
if (/^\d+$/.test(value) && index > 0) {
|
|
459
|
+
const cached = this.store.getCachedThread(scopeId, index);
|
|
460
|
+
if (!cached)
|
|
461
|
+
return null;
|
|
462
|
+
const response = await this.app.getClient().session.get({
|
|
463
|
+
sessionID: cached.threadId,
|
|
464
|
+
...(cached.cwd ? { directory: cached.cwd } : {}),
|
|
465
|
+
});
|
|
466
|
+
return response.error ? null : response.data ?? null;
|
|
467
|
+
}
|
|
468
|
+
const listed = await this.app.getClient().experimental.session.list({ limit: 100 });
|
|
469
|
+
if (listed.error)
|
|
470
|
+
return null;
|
|
471
|
+
const candidates = (listed.data ?? []).filter((session) => session.id === value || session.id.startsWith(value));
|
|
472
|
+
return candidates.length === 1 ? candidates[0] : null;
|
|
473
|
+
}
|
|
474
|
+
async openSession(scopeId, raw, locale) {
|
|
475
|
+
if (!raw) {
|
|
476
|
+
await this.send(scopeId, localize(locale, '用法:/open <编号|会话ID>', 'Usage: /open <number|session-id>'));
|
|
477
|
+
return;
|
|
478
|
+
}
|
|
479
|
+
const session = await this.resolveSessionTarget(scopeId, raw);
|
|
480
|
+
if (!session)
|
|
481
|
+
throw new Error(localize(locale, `找不到唯一会话:${raw}`, `Could not resolve one session: ${raw}`));
|
|
482
|
+
if (session.time.archived)
|
|
483
|
+
throw new Error(localize(locale, '该会话已归档。先用 /threads archived,再用 /unarchive <编号>。', 'That session is archived. Use /threads archived, then /unarchive <number>.'));
|
|
484
|
+
const access = this.store.getChatSettings(scopeId)?.accessPreset ?? 'default';
|
|
485
|
+
unwrap(await this.app.getClient().session.update({
|
|
486
|
+
sessionID: session.id,
|
|
487
|
+
directory: session.directory,
|
|
488
|
+
permission: permissionRules(access),
|
|
489
|
+
}), 'session.update');
|
|
490
|
+
this.store.setBinding(scopeId, session.id, session.directory);
|
|
491
|
+
await this.send(scopeId, localize(locale, `✅ 已打开:${session.title}\n\`${session.id}\`\n目录:\`${session.directory}\``, `✅ Bound: ${session.title}\n\`${session.id}\`\nDirectory: \`${session.directory}\``));
|
|
492
|
+
}
|
|
493
|
+
async watchSession(scopeId, raw, locale) {
|
|
494
|
+
const session = await this.resolveSessionTarget(scopeId, raw);
|
|
495
|
+
if (!session) {
|
|
496
|
+
await this.send(scopeId, localize(locale, '没有可观察的会话。先用 /threads。', 'No session to watch. Use /threads first.'));
|
|
497
|
+
return;
|
|
498
|
+
}
|
|
499
|
+
const current = this.watchers.get(scopeId);
|
|
500
|
+
if (current?.flushTimer)
|
|
501
|
+
clearTimeout(current.flushTimer);
|
|
502
|
+
this.watchers.set(scopeId, {
|
|
503
|
+
sessionId: session.id,
|
|
504
|
+
cwd: session.directory,
|
|
505
|
+
parts: new Map(),
|
|
506
|
+
messageIds: [],
|
|
507
|
+
renderedChunks: [],
|
|
508
|
+
flushTimer: null,
|
|
509
|
+
flushPromise: Promise.resolve(),
|
|
510
|
+
lastFlush: 0,
|
|
511
|
+
});
|
|
512
|
+
await this.send(scopeId, localize(locale, `👁 正在观察:${session.title}\n\`${session.id}\``, `👁 Watching: ${session.title}\n\`${session.id}\``));
|
|
513
|
+
}
|
|
514
|
+
async unwatchSession(scopeId, locale) {
|
|
515
|
+
const watch = this.watchers.get(scopeId);
|
|
516
|
+
if (watch?.flushTimer)
|
|
517
|
+
clearTimeout(watch.flushTimer);
|
|
518
|
+
this.watchers.delete(scopeId);
|
|
519
|
+
await this.send(scopeId, localize(locale, watch ? '已停止观察。' : '当前没有观察会话。', watch ? 'Stopped watching.' : 'No watched session.'));
|
|
520
|
+
}
|
|
521
|
+
async showStatus(scopeId, locale) {
|
|
522
|
+
const binding = this.store.getBinding(scopeId);
|
|
523
|
+
const settings = this.store.getChatSettings(scopeId);
|
|
524
|
+
const status = this.app.getServerStatus();
|
|
525
|
+
const turn = this.activeTurns.get(scopeId);
|
|
526
|
+
const prefs = readPrefs(settings);
|
|
527
|
+
const lines = [
|
|
528
|
+
`OpenCode serve: ${status.connected ? '✅' : '❌'} ${status.version ?? ''}`.trim(),
|
|
529
|
+
`${localize(locale, '进程', 'Process')}: ${status.pid ?? '—'} · ${status.url ?? '—'}`,
|
|
530
|
+
`${localize(locale, '会话', 'Session')}: ${binding ? `\`${binding.threadId}\`` : localize(locale, '无', 'none')}`,
|
|
531
|
+
`${localize(locale, '目录', 'Directory')}: ${binding?.cwd ? `\`${binding.cwd}\`` : '—'}`,
|
|
532
|
+
`${localize(locale, '模型', 'Model')}: ${formatStoredModel(settings?.model, locale)}`,
|
|
533
|
+
`${localize(locale, '档位', 'Variant')}: ${prefs.variant ?? localize(locale, '默认', 'default')}`,
|
|
534
|
+
`${localize(locale, 'Agent', 'Agent')}: ${settings?.collaborationMode === 'plan' ? 'plan' : prefs.agent ?? 'build'}`,
|
|
535
|
+
`${localize(locale, '权限', 'Access')}: ${settings?.accessPreset ?? 'default'}`,
|
|
536
|
+
`${localize(locale, '运行中新消息', 'Active messages')}: ${settings?.activeTurnMessageMode ?? 'steer'}`,
|
|
537
|
+
`${localize(locale, '回复', 'Turn')}: ${turn ? '⏳' : 'idle'} · ${localize(locale, '排队', 'queued')} ${this.queuedPrompts.get(scopeId)?.length ?? 0}`,
|
|
538
|
+
];
|
|
539
|
+
await this.send(scopeId, lines.join('\n'));
|
|
540
|
+
}
|
|
541
|
+
async showSetup(scopeId, locale, messageId) {
|
|
542
|
+
const settings = this.store.getChatSettings(scopeId);
|
|
543
|
+
const prefs = readPrefs(settings);
|
|
544
|
+
const [providers, agentsResponse] = await Promise.all([
|
|
545
|
+
this.listProviders(this.store.getBinding(scopeId)?.cwd),
|
|
546
|
+
this.app.getClient().app.agents({ directory: this.store.getBinding(scopeId)?.cwd ?? this.config.defaultCwd }),
|
|
547
|
+
]);
|
|
548
|
+
const models = providers.flatMap((provider) => provider.models);
|
|
549
|
+
const selectedModel = settings?.model ? parseStoredModel(settings.model) : null;
|
|
550
|
+
const model = selectedModel
|
|
551
|
+
? models.find((item) => item.providerId === selectedModel.providerId && item.modelId === selectedModel.modelId)
|
|
552
|
+
: providers.map((provider) => provider.models.find((item) => item.modelId === provider.defaultModelId)).find(Boolean) ?? null;
|
|
553
|
+
const variants = model?.variants ?? [];
|
|
554
|
+
const agents = unwrap(agentsResponse, 'agent.list').filter((agent) => !agent.hidden && agent.mode !== 'subagent');
|
|
555
|
+
const access = settings?.accessPreset ?? 'default';
|
|
556
|
+
const activeMode = settings?.activeTurnMessageMode ?? 'steer';
|
|
557
|
+
const collaborationMode = settings?.collaborationMode ?? 'default';
|
|
558
|
+
const currentAgent = collaborationMode === 'plan' ? 'plan' : prefs.agent ?? 'build';
|
|
559
|
+
const displayModel = formatStoredModel(settings?.model, locale);
|
|
560
|
+
const displayVariant = prefs.variant ?? localize(locale, '自动', 'Auto');
|
|
561
|
+
const text = localize(locale, `⚙️ OpenCode 设置\n当前:${displayModel} · ${displayVariant} · ${currentAgent} · ${access}\n\n模型:${displayModel}\n推理档位:${displayVariant}\nFast:OpenCode serve 没有 Codex service tier 等价项\nAgent:${currentAgent}\n权限:${access}\n运行中新消息:${activeMode}`, `⚙️ OpenCode settings\nCurrent: ${displayModel} · ${displayVariant} · ${currentAgent} · ${access}\n\nModel: ${displayModel}\nReasoning variant: ${displayVariant}\nFast: OpenCode serve has no Codex service-tier equivalent\nAgent: ${currentAgent}\nAccess: ${access}\nActive messages: ${activeMode}`);
|
|
562
|
+
const actions = [
|
|
563
|
+
[{
|
|
564
|
+
label: `🧠 ${localize(locale, '模型', 'Model')} · ${clip(displayModel, 24)}`,
|
|
565
|
+
action: { scopeId, kind: 'models', value: '', origin: 'setup' },
|
|
566
|
+
}],
|
|
567
|
+
];
|
|
568
|
+
if (variants.length > 0) {
|
|
569
|
+
const variantActions = [
|
|
570
|
+
{ label: selectedLabel(prefs.variant === null, localize(locale, '自动', 'Auto')), action: { scopeId, kind: 'variant', value: 'default', origin: 'setup' } },
|
|
571
|
+
...variants.map((variant) => ({
|
|
572
|
+
label: selectedLabel(prefs.variant === variant, variant),
|
|
573
|
+
action: { scopeId, kind: 'variant', value: variant, origin: 'setup' },
|
|
574
|
+
})),
|
|
575
|
+
];
|
|
576
|
+
for (let index = 0; index < variantActions.length; index += 3)
|
|
577
|
+
actions.push(variantActions.slice(index, index + 3));
|
|
578
|
+
}
|
|
579
|
+
else {
|
|
580
|
+
actions.push([{
|
|
581
|
+
label: localize(locale, '推理档位:先选择模型', 'Reasoning: choose a model'),
|
|
582
|
+
action: { scopeId, kind: 'models', value: '', origin: 'setup' },
|
|
583
|
+
}]);
|
|
584
|
+
}
|
|
585
|
+
actions.push([
|
|
586
|
+
{ label: selectedLabel(access === 'read-only', '🔒 read-only'), action: { scopeId, kind: 'access', value: 'read-only', origin: 'setup' } },
|
|
587
|
+
{ label: selectedLabel(access === 'default', '🛂 default'), action: { scopeId, kind: 'access', value: 'default', origin: 'setup' } },
|
|
588
|
+
{ label: selectedLabel(access === 'full-access', '🔓 full-access'), action: { scopeId, kind: 'access', value: 'full-access', origin: 'setup' } },
|
|
589
|
+
]);
|
|
590
|
+
const agentActions = agents.map((agent) => ({
|
|
591
|
+
label: selectedLabel(currentAgent === agent.name, agent.name === 'plan' ? '📝 Plan' : `🤖 ${agent.name}`),
|
|
592
|
+
action: {
|
|
593
|
+
scopeId,
|
|
594
|
+
kind: agent.name === 'plan' ? 'mode' : 'agent',
|
|
595
|
+
value: agent.name === 'plan' ? 'plan' : agent.name,
|
|
596
|
+
origin: 'setup',
|
|
597
|
+
},
|
|
598
|
+
}));
|
|
599
|
+
for (let index = 0; index < agentActions.length; index += 3)
|
|
600
|
+
actions.push(agentActions.slice(index, index + 3));
|
|
601
|
+
actions.push([
|
|
602
|
+
{ label: selectedLabel(activeMode === 'steer', localize(locale, '引导当前回复', 'Steer current turn')), action: { scopeId, kind: 'active', value: 'steer', origin: 'setup' } },
|
|
603
|
+
{ label: selectedLabel(activeMode === 'queue', localize(locale, '排队到下一轮', 'Queue next turn')), action: { scopeId, kind: 'active', value: 'queue', origin: 'setup' } },
|
|
604
|
+
]);
|
|
605
|
+
actions.push([{
|
|
606
|
+
label: localize(locale, 'Fast 无等价项', 'Fast unsupported'),
|
|
607
|
+
action: { scopeId, kind: 'notice', value: 'fast', origin: 'setup' },
|
|
608
|
+
}]);
|
|
609
|
+
await this.sendOrEditPanel(scopeId, text, this.setupKeyboard(actions), messageId);
|
|
610
|
+
}
|
|
611
|
+
setupKeyboard(rows) {
|
|
612
|
+
const keyboard = rows.map((row) => row.map((entry) => {
|
|
613
|
+
const key = randomKey();
|
|
614
|
+
this.setupActions.set(key, entry.action);
|
|
615
|
+
return { text: entry.label, callback_data: `${SETUP_CALLBACK_PREFIX}${key}` };
|
|
616
|
+
}));
|
|
617
|
+
while (this.setupActions.size > 2_000) {
|
|
618
|
+
const oldest = this.setupActions.keys().next().value;
|
|
619
|
+
if (!oldest)
|
|
620
|
+
break;
|
|
621
|
+
this.setupActions.delete(oldest);
|
|
622
|
+
}
|
|
623
|
+
return keyboard;
|
|
624
|
+
}
|
|
625
|
+
async sendOrEditPanel(scopeId, text, keyboard, messageId) {
|
|
626
|
+
if (messageId === undefined)
|
|
627
|
+
await this.messaging.sendPlain(scopeId, text, keyboard);
|
|
628
|
+
else
|
|
629
|
+
await this.messaging.editPlain(scopeId, messageId, text, keyboard);
|
|
630
|
+
}
|
|
631
|
+
async listProviders(cwd) {
|
|
632
|
+
const response = await this.app.getClient().provider.list({ ...(cwd ? { directory: cwd } : {}) });
|
|
633
|
+
const data = unwrap(response, 'provider.list');
|
|
634
|
+
return data.connected.flatMap((providerId) => {
|
|
635
|
+
const provider = data.all.find((item) => item.id === providerId);
|
|
636
|
+
if (!provider)
|
|
637
|
+
return [];
|
|
638
|
+
const models = Object.values(provider.models).map((model) => ({
|
|
639
|
+
providerId: provider.id,
|
|
640
|
+
modelId: model.id,
|
|
641
|
+
name: model.name,
|
|
642
|
+
variants: Object.entries(model.variants ?? {}).filter(([, value]) => value.disabled !== true).map(([key]) => key),
|
|
643
|
+
})).sort((a, b) => a.name.localeCompare(b.name));
|
|
644
|
+
return [{ providerId: provider.id, name: provider.name, defaultModelId: data.default[provider.id] ?? null, models }];
|
|
645
|
+
});
|
|
646
|
+
}
|
|
647
|
+
async listModels(cwd) {
|
|
648
|
+
return (await this.listProviders(cwd)).flatMap((provider) => provider.models);
|
|
649
|
+
}
|
|
650
|
+
async showModels(scopeId, rawProvider, locale, messageId, origin = 'models') {
|
|
651
|
+
const providers = await this.listProviders(this.store.getBinding(scopeId)?.cwd);
|
|
652
|
+
if (providers.length === 0) {
|
|
653
|
+
await this.send(scopeId, localize(locale, '没有已连接 Provider 的模型。请先在终端运行 opencode auth。', 'No models from connected providers. Run opencode auth in a terminal.'));
|
|
654
|
+
return;
|
|
655
|
+
}
|
|
656
|
+
if (!rawProvider) {
|
|
657
|
+
const current = formatStoredModel(this.store.getChatSettings(scopeId)?.model, locale);
|
|
658
|
+
const actions = [[{
|
|
659
|
+
label: selectedLabel(!this.store.getChatSettings(scopeId)?.model, localize(locale, '自动 / 服务端默认', 'Auto / server default')),
|
|
660
|
+
action: { scopeId, kind: 'model', value: 'default', origin },
|
|
661
|
+
}]];
|
|
662
|
+
for (const provider of providers) {
|
|
663
|
+
actions.push([{
|
|
664
|
+
label: `${provider.name} · ${provider.models.length}`,
|
|
665
|
+
action: { scopeId, kind: 'provider', value: provider.providerId, origin },
|
|
666
|
+
}]);
|
|
667
|
+
}
|
|
668
|
+
if (origin === 'setup')
|
|
669
|
+
actions.push([{ label: localize(locale, '← 返回设置', '← Back to settings'), action: { scopeId, kind: 'setup', value: '' } }]);
|
|
670
|
+
const text = [
|
|
671
|
+
localize(locale, '🧠 选择模型 Provider', '🧠 Choose a model provider'),
|
|
672
|
+
`${localize(locale, '当前模型', 'Current model')}:${current}`,
|
|
673
|
+
'',
|
|
674
|
+
...providers.map((provider, index) => `${index + 1}. ${provider.name} · \`${provider.providerId}\` · ${provider.models.length} models`),
|
|
675
|
+
].join('\n');
|
|
676
|
+
await this.sendOrEditPanel(scopeId, text, this.setupKeyboard(actions), messageId);
|
|
677
|
+
return;
|
|
678
|
+
}
|
|
679
|
+
const normalized = rawProvider.toLowerCase();
|
|
680
|
+
const provider = providers.find((item) => item.providerId.toLowerCase() === normalized || item.name.toLowerCase() === normalized);
|
|
681
|
+
if (!provider)
|
|
682
|
+
throw new Error(localize(locale, `未知 Provider:${rawProvider}`, `Unknown provider: ${rawProvider}`));
|
|
683
|
+
const stored = this.store.getChatSettings(scopeId)?.model;
|
|
684
|
+
const selected = stored ? parseStoredModel(stored) : null;
|
|
685
|
+
const entries = provider.models.map((model) => ({
|
|
686
|
+
label: selectedLabel(selected?.providerId === model.providerId && selected.modelId === model.modelId, clip(model.name, 26)),
|
|
687
|
+
action: { scopeId, kind: 'model', value: storeModel(model.providerId, model.modelId), origin },
|
|
688
|
+
}));
|
|
689
|
+
const actions = [];
|
|
690
|
+
for (let index = 0; index < entries.length; index += 2)
|
|
691
|
+
actions.push(entries.slice(index, index + 2));
|
|
692
|
+
actions.push([{
|
|
693
|
+
label: localize(locale, '← Provider', '← Providers'),
|
|
694
|
+
action: { scopeId, kind: 'models', value: '', origin },
|
|
695
|
+
}]);
|
|
696
|
+
const text = [
|
|
697
|
+
`🧠 ${provider.name}`,
|
|
698
|
+
`\`${provider.providerId}\` · ${provider.models.length} models`,
|
|
699
|
+
`${localize(locale, '当前模型', 'Current model')}:${formatStoredModel(stored, locale)}`,
|
|
700
|
+
'',
|
|
701
|
+
localize(locale, '选择一个模型:', 'Choose a model:'),
|
|
702
|
+
].join('\n');
|
|
703
|
+
await this.sendOrEditPanel(scopeId, text, this.setupKeyboard(actions), messageId);
|
|
704
|
+
}
|
|
705
|
+
async setModel(scopeId, raw, locale) {
|
|
706
|
+
if (!raw) {
|
|
707
|
+
await this.showModels(scopeId, '', locale);
|
|
708
|
+
return;
|
|
709
|
+
}
|
|
710
|
+
if (raw === 'default' || raw === 'reset') {
|
|
711
|
+
const settings = this.store.getChatSettings(scopeId);
|
|
712
|
+
this.store.setChatSettings(scopeId, null, settings?.reasoningEffort ?? null);
|
|
713
|
+
this.writePrefs(scopeId, { ...readPrefs(settings), variant: null });
|
|
714
|
+
await this.send(scopeId, localize(locale, '模型已恢复服务端默认。', 'Model reset to server default.'));
|
|
715
|
+
return;
|
|
716
|
+
}
|
|
717
|
+
const models = await this.listModels(this.store.getBinding(scopeId)?.cwd);
|
|
718
|
+
const index = /^\d+$/.test(raw) ? Number.parseInt(raw, 10) - 1 : -1;
|
|
719
|
+
const normalized = raw.replace(/^`|`$/g, '');
|
|
720
|
+
const model = index >= 0 ? models[index] : models.find((item) => `${item.providerId}/${item.modelId}` === normalized || item.modelId === normalized || storeModel(item.providerId, item.modelId) === normalized);
|
|
721
|
+
if (!model)
|
|
722
|
+
throw new Error(localize(locale, `未知模型:${raw}`, `Unknown model: ${raw}`));
|
|
723
|
+
this.store.setChatSettings(scopeId, storeModel(model.providerId, model.modelId), null);
|
|
724
|
+
const prefs = readPrefs(this.store.getChatSettings(scopeId));
|
|
725
|
+
if (prefs.variant && !model.variants.includes(prefs.variant))
|
|
726
|
+
this.writePrefs(scopeId, { ...prefs, variant: null });
|
|
727
|
+
await this.send(scopeId, localize(locale, `模型 → \`${model.providerId}/${model.modelId}\``, `Model → \`${model.providerId}/${model.modelId}\``));
|
|
728
|
+
}
|
|
729
|
+
async setVariant(scopeId, raw, locale) {
|
|
730
|
+
const settings = this.store.getChatSettings(scopeId);
|
|
731
|
+
const prefs = readPrefs(settings);
|
|
732
|
+
const model = settings?.model ? parseStoredModel(settings.model) : null;
|
|
733
|
+
if (!raw) {
|
|
734
|
+
await this.showSetup(scopeId, locale);
|
|
735
|
+
return;
|
|
736
|
+
}
|
|
737
|
+
if (raw === 'default' || raw === 'reset' || raw === 'none') {
|
|
738
|
+
this.writePrefs(scopeId, { ...prefs, variant: null });
|
|
739
|
+
await this.send(scopeId, localize(locale, 'Variant 已恢复默认。', 'Variant reset to default.'));
|
|
740
|
+
return;
|
|
741
|
+
}
|
|
742
|
+
if (!model)
|
|
743
|
+
throw new Error(localize(locale, '先用 /model 选择模型。', 'Choose a model first with /model.'));
|
|
744
|
+
const choices = await this.listModels(this.store.getBinding(scopeId)?.cwd);
|
|
745
|
+
const selected = choices.find((item) => item.providerId === model.providerId && item.modelId === model.modelId);
|
|
746
|
+
if (!selected?.variants.includes(raw))
|
|
747
|
+
throw new Error(localize(locale, `当前模型不支持 variant:${raw}`, `Selected model does not support variant: ${raw}`));
|
|
748
|
+
this.writePrefs(scopeId, { ...prefs, variant: raw });
|
|
749
|
+
await this.send(scopeId, `Variant → \`${raw}\``);
|
|
750
|
+
}
|
|
751
|
+
async setMode(scopeId, raw, locale) {
|
|
752
|
+
if (!raw) {
|
|
753
|
+
await this.showSetup(scopeId, locale);
|
|
754
|
+
return;
|
|
755
|
+
}
|
|
756
|
+
if (raw !== 'default' && raw !== 'plan')
|
|
757
|
+
throw new Error(localize(locale, '用法:/mode <default|plan>', 'Usage: /mode <default|plan>'));
|
|
758
|
+
this.store.setChatCollaborationMode(scopeId, raw);
|
|
759
|
+
await this.send(scopeId, raw === 'plan'
|
|
760
|
+
? localize(locale, '📝 下一轮将使用 OpenCode Plan Agent,发送后自动回到 Agent。', '📝 OpenCode Plan Agent is armed for the next turn, then returns to Agent.')
|
|
761
|
+
: localize(locale, '🤖 已切回 Agent。', '🤖 Switched back to Agent.'));
|
|
762
|
+
}
|
|
763
|
+
async setAgent(scopeId, raw, locale) {
|
|
764
|
+
const settings = this.store.getChatSettings(scopeId);
|
|
765
|
+
const prefs = readPrefs(settings);
|
|
766
|
+
if (!raw) {
|
|
767
|
+
this.store.setChatCollaborationMode(scopeId, 'default');
|
|
768
|
+
await this.send(scopeId, localize(locale, `🤖 已切回 Agent:${prefs.agent ?? 'build'}`, `🤖 Switched back to Agent: ${prefs.agent ?? 'build'}`));
|
|
769
|
+
return;
|
|
770
|
+
}
|
|
771
|
+
if (raw === 'default' || raw === 'build') {
|
|
772
|
+
this.writePrefs(scopeId, { ...prefs, agent: raw === 'build' ? 'build' : null });
|
|
773
|
+
this.store.setChatCollaborationMode(scopeId, 'default');
|
|
774
|
+
await this.send(scopeId, localize(locale, 'Agent → build', 'Agent → build'));
|
|
775
|
+
return;
|
|
776
|
+
}
|
|
777
|
+
const response = await this.app.getClient().app.agents({ directory: this.store.getBinding(scopeId)?.cwd ?? this.config.defaultCwd });
|
|
778
|
+
const agents = unwrap(response, 'agent.list').filter((agent) => !agent.hidden && agent.mode !== 'subagent');
|
|
779
|
+
const match = agents.find((agent) => agent.name === raw);
|
|
780
|
+
if (!match) {
|
|
781
|
+
await this.send(scopeId, localize(locale, `未知 Agent:${raw}\n可用:${agents.map((agent) => `\`${agent.name}\``).join('、')}`, `Unknown agent: ${raw}\nAvailable: ${agents.map((agent) => `\`${agent.name}\``).join(', ')}`));
|
|
782
|
+
return;
|
|
783
|
+
}
|
|
784
|
+
this.writePrefs(scopeId, { ...prefs, agent: match.name });
|
|
785
|
+
this.store.setChatCollaborationMode(scopeId, 'default');
|
|
786
|
+
await this.send(scopeId, `Agent → \`${match.name}\``);
|
|
787
|
+
}
|
|
788
|
+
async setAccess(scopeId, raw, locale) {
|
|
789
|
+
if (!raw) {
|
|
790
|
+
await this.showSetup(scopeId, locale);
|
|
791
|
+
return;
|
|
792
|
+
}
|
|
793
|
+
if (raw !== 'read-only' && raw !== 'default' && raw !== 'full-access') {
|
|
794
|
+
throw new Error(localize(locale, '用法:/permissions <read-only|default|full-access>', 'Usage: /permissions <read-only|default|full-access>'));
|
|
795
|
+
}
|
|
796
|
+
await this.applyAccess(scopeId, raw);
|
|
797
|
+
await this.send(scopeId, localize(locale, `权限 → ${raw}`, `Access → ${raw}`));
|
|
798
|
+
}
|
|
799
|
+
async applyAccess(scopeId, access) {
|
|
800
|
+
this.store.setChatAccessPreset(scopeId, access);
|
|
801
|
+
const binding = this.store.getBinding(scopeId);
|
|
802
|
+
if (binding) {
|
|
803
|
+
const response = await this.app.getClient().session.update({
|
|
804
|
+
sessionID: binding.threadId,
|
|
805
|
+
...(binding.cwd ? { directory: binding.cwd } : {}),
|
|
806
|
+
permission: permissionRules(access),
|
|
807
|
+
});
|
|
808
|
+
if (response.error)
|
|
809
|
+
throw new Error(formatSdkError(response.error));
|
|
810
|
+
}
|
|
811
|
+
}
|
|
812
|
+
async setActiveMode(scopeId, raw, locale) {
|
|
813
|
+
if (!raw) {
|
|
814
|
+
await this.showSetup(scopeId, locale);
|
|
815
|
+
return;
|
|
816
|
+
}
|
|
817
|
+
if (raw !== 'steer' && raw !== 'queue')
|
|
818
|
+
throw new Error(localize(locale, '用法:/active <steer|queue>', 'Usage: /active <steer|queue>'));
|
|
819
|
+
this.store.setChatActiveTurnMessageMode(scopeId, raw);
|
|
820
|
+
await this.send(scopeId, localize(locale, `运行中新消息 → ${raw}`, `Active-turn messages → ${raw}`));
|
|
821
|
+
}
|
|
822
|
+
writePrefs(scopeId, prefs) {
|
|
823
|
+
this.store.setChatServiceTier(scopeId, `opencode:${JSON.stringify(prefs)}`);
|
|
824
|
+
}
|
|
825
|
+
async sendWithBehavior(event, text, locale, behavior) {
|
|
826
|
+
if (!text)
|
|
827
|
+
throw new Error(localize(locale, `用法:/${behavior} <消息>`, `Usage: /${behavior} <message>`));
|
|
828
|
+
await this.dispatchPrompt(event, text, locale, behavior);
|
|
829
|
+
}
|
|
830
|
+
async takeOver(event, text, locale) {
|
|
831
|
+
if (!text)
|
|
832
|
+
throw new Error(localize(locale, '用法:/takeover <消息>', 'Usage: /takeover <message>'));
|
|
833
|
+
const active = this.activeTurns.get(event.scopeId);
|
|
834
|
+
if (!active) {
|
|
835
|
+
await this.dispatchPrompt(event, text, locale, 'steer');
|
|
836
|
+
return;
|
|
837
|
+
}
|
|
838
|
+
const queue = this.queuedPrompts.get(event.scopeId) ?? [];
|
|
839
|
+
queue.unshift({ event, text, locale });
|
|
840
|
+
this.queuedPrompts.set(event.scopeId, queue);
|
|
841
|
+
const response = await this.app.getClient().session.abort({ sessionID: active.sessionId, directory: active.cwd });
|
|
842
|
+
if (response.error) {
|
|
843
|
+
queue.shift();
|
|
844
|
+
if (queue.length === 0)
|
|
845
|
+
this.queuedPrompts.delete(event.scopeId);
|
|
846
|
+
throw new Error(formatSdkError(response.error));
|
|
847
|
+
}
|
|
848
|
+
await this.finishSession(active.sessionId);
|
|
849
|
+
await this.send(event.scopeId, localize(locale, '↪️ 已中断并接管当前会话。', '↪️ Interrupted and took over the current session.'));
|
|
850
|
+
}
|
|
851
|
+
async dispatchPrompt(event, text, locale, behavior) {
|
|
852
|
+
const active = this.activeTurns.get(event.scopeId);
|
|
853
|
+
const activeMode = behavior ?? this.store.getChatSettings(event.scopeId)?.activeTurnMessageMode ?? 'steer';
|
|
854
|
+
if (active && activeMode === 'queue') {
|
|
855
|
+
const queue = this.queuedPrompts.get(event.scopeId) ?? [];
|
|
856
|
+
queue.push({ event, text, locale });
|
|
857
|
+
this.queuedPrompts.set(event.scopeId, queue);
|
|
858
|
+
await this.send(event.scopeId, localize(locale, `⏭ 已排队(${queue.length})。`, `⏭ Queued (${queue.length}).`));
|
|
859
|
+
return;
|
|
860
|
+
}
|
|
861
|
+
const binding = this.store.getBinding(event.scopeId);
|
|
862
|
+
const session = binding ? await this.resolveSessionTarget(event.scopeId, '') : await this.createAndBind(event.scopeId, null, locale);
|
|
863
|
+
if (!session)
|
|
864
|
+
throw new Error(localize(locale, '当前 OpenCode 会话不存在,请 /new。', 'The bound OpenCode session no longer exists; use /new.'));
|
|
865
|
+
const cwd = session.directory || binding?.cwd || this.config.defaultCwd;
|
|
866
|
+
const parts = await this.buildPromptParts(event, session.id, cwd, text, locale);
|
|
867
|
+
const settings = this.store.getChatSettings(event.scopeId);
|
|
868
|
+
const prefs = readPrefs(settings);
|
|
869
|
+
const model = settings?.model ? parseStoredModel(settings.model) : null;
|
|
870
|
+
const agent = settings?.collaborationMode === 'plan' ? 'plan' : prefs.agent ?? undefined;
|
|
871
|
+
if (!active)
|
|
872
|
+
this.startTrackedTurn(event.scopeId, session.id, cwd);
|
|
873
|
+
await this.messaging.sendTypingInScope(event.scopeId);
|
|
874
|
+
const response = await this.app.getClient().session.promptAsync({
|
|
875
|
+
sessionID: session.id,
|
|
876
|
+
directory: cwd,
|
|
877
|
+
parts,
|
|
878
|
+
...(model ? { model: { providerID: model.providerId, modelID: model.modelId } } : {}),
|
|
879
|
+
...(prefs.variant ? { variant: prefs.variant } : {}),
|
|
880
|
+
...(agent ? { agent } : {}),
|
|
881
|
+
});
|
|
882
|
+
if (response.error) {
|
|
883
|
+
if (!active)
|
|
884
|
+
this.activeTurns.delete(event.scopeId);
|
|
885
|
+
throw new Error(formatSdkError(response.error));
|
|
886
|
+
}
|
|
887
|
+
if (settings?.collaborationMode === 'plan')
|
|
888
|
+
this.store.setChatCollaborationMode(event.scopeId, 'default');
|
|
889
|
+
this.app.watchSessionUntilIdle(session.id, cwd);
|
|
890
|
+
if (active)
|
|
891
|
+
await this.send(event.scopeId, localize(locale, '↪️ 已追加到当前 OpenCode 回复。', '↪️ Steered the active OpenCode turn.'));
|
|
892
|
+
}
|
|
893
|
+
startTrackedTurn(scopeId, sessionId, cwd) {
|
|
894
|
+
const turn = {
|
|
895
|
+
sessionId,
|
|
896
|
+
cwd,
|
|
897
|
+
parts: new Map(),
|
|
898
|
+
messageIds: [],
|
|
899
|
+
renderedChunks: [],
|
|
900
|
+
flushTimer: null,
|
|
901
|
+
flushPromise: Promise.resolve(),
|
|
902
|
+
lastFlush: 0,
|
|
903
|
+
toolMessageId: null,
|
|
904
|
+
toolLines: new Map(),
|
|
905
|
+
toolTimer: null,
|
|
906
|
+
toolPromise: Promise.resolve(),
|
|
907
|
+
};
|
|
908
|
+
this.activeTurns.set(scopeId, turn);
|
|
909
|
+
return turn;
|
|
910
|
+
}
|
|
911
|
+
async buildPromptParts(event, sessionId, cwd, text, locale) {
|
|
912
|
+
if (event.attachments.length === 0)
|
|
913
|
+
return [{ type: 'text', text }];
|
|
914
|
+
const staged = await this.stageAttachments(cwd, sessionId, event.attachments, locale);
|
|
915
|
+
const parts = [{ type: 'text', text: buildAttachmentPrompt(text, staged) }];
|
|
916
|
+
for (const attachment of staged) {
|
|
917
|
+
parts.push({
|
|
918
|
+
type: 'file',
|
|
919
|
+
mime: attachment.mimeType || 'application/octet-stream',
|
|
920
|
+
filename: attachment.fileName,
|
|
921
|
+
url: pathToFileURL(attachment.localPath).href,
|
|
922
|
+
});
|
|
923
|
+
}
|
|
924
|
+
return parts;
|
|
925
|
+
}
|
|
926
|
+
async stageAttachments(cwd, sessionId, attachments, locale) {
|
|
927
|
+
const staged = [];
|
|
928
|
+
for (const attachment of attachments) {
|
|
929
|
+
const remote = attachment.localPath ? null : await this.messaging.getFile(attachment.fileId);
|
|
930
|
+
const size = attachment.fileSize ?? remote?.file_size ?? null;
|
|
931
|
+
if (size !== null && size > TELEGRAM_BOT_API_DOWNLOAD_LIMIT_BYTES) {
|
|
932
|
+
throw new Error(localize(locale, `附件超过 Telegram 20MB 下载限制:${attachment.fileName ?? attachment.fileUniqueId}`, `Attachment exceeds Telegram's 20MB download limit: ${attachment.fileName ?? attachment.fileUniqueId}`));
|
|
933
|
+
}
|
|
934
|
+
const remotePath = attachment.localPath ? path.basename(attachment.localPath) : remote?.file_path;
|
|
935
|
+
if (!remotePath)
|
|
936
|
+
throw new Error(localize(locale, 'Telegram 没有返回附件路径。', 'Telegram did not return an attachment path.'));
|
|
937
|
+
const planned = planAttachmentStoragePath(cwd, sessionId, attachment, remotePath);
|
|
938
|
+
await fs.mkdir(path.dirname(planned.localPath), { recursive: true });
|
|
939
|
+
if (attachment.localPath)
|
|
940
|
+
await fs.copyFile(attachment.localPath, planned.localPath);
|
|
941
|
+
else
|
|
942
|
+
await this.messaging.downloadResolvedFile(remotePath, planned.localPath);
|
|
943
|
+
const resolved = { ...attachment, fileName: planned.fileName, fileSize: size };
|
|
944
|
+
staged.push({
|
|
945
|
+
...resolved,
|
|
946
|
+
fileName: planned.fileName,
|
|
947
|
+
localPath: planned.localPath,
|
|
948
|
+
relativePath: planned.relativePath,
|
|
949
|
+
nativeImage: isNativeImageAttachment(resolved),
|
|
950
|
+
});
|
|
951
|
+
}
|
|
952
|
+
return staged;
|
|
953
|
+
}
|
|
954
|
+
async showHistory(scopeId, rawLimit, locale) {
|
|
955
|
+
const session = await this.requireBoundSession(scopeId, locale);
|
|
956
|
+
const limit = clampInt(rawLimit, 10, 1, 30);
|
|
957
|
+
const response = await this.app.getClient().session.messages({ sessionID: session.id, directory: session.directory, limit });
|
|
958
|
+
const messages = unwrap(response, 'session.messages');
|
|
959
|
+
const lines = [localize(locale, `最近 ${Math.min(limit, messages.length)} 条消息:`, `Latest ${Math.min(limit, messages.length)} messages:`), ''];
|
|
960
|
+
for (const row of messages.slice(-limit)) {
|
|
961
|
+
const text = row.parts.filter((part) => part.type === 'text').map((part) => part.text).join('\n').trim();
|
|
962
|
+
lines.push(`${row.info.role === 'assistant' ? '🤖' : '🧑'} ${clip(text || localize(locale, '(非文本消息)', '(non-text message)'), 300)}`);
|
|
963
|
+
}
|
|
964
|
+
await this.send(scopeId, lines.join('\n'));
|
|
965
|
+
}
|
|
966
|
+
async renameSession(scopeId, title, locale) {
|
|
967
|
+
if (!title)
|
|
968
|
+
throw new Error(localize(locale, '用法:/rename <名称>', 'Usage: /rename <name>'));
|
|
969
|
+
const session = await this.requireBoundSession(scopeId, locale);
|
|
970
|
+
unwrap(await this.app.getClient().session.update({ sessionID: session.id, directory: session.directory, title }), 'session.update');
|
|
971
|
+
await this.send(scopeId, localize(locale, `已重命名为:${title}`, `Renamed to: ${title}`));
|
|
972
|
+
}
|
|
973
|
+
async forkSession(scopeId, title, locale) {
|
|
974
|
+
const session = await this.requireBoundSession(scopeId, locale);
|
|
975
|
+
const fork = unwrap(await this.app.getClient().session.fork({ sessionID: session.id, directory: session.directory }), 'session.fork');
|
|
976
|
+
if (title)
|
|
977
|
+
unwrap(await this.app.getClient().session.update({ sessionID: fork.id, directory: fork.directory, title }), 'session.update');
|
|
978
|
+
this.store.setBinding(scopeId, fork.id, fork.directory);
|
|
979
|
+
await this.send(scopeId, localize(locale, `🍴 已 Fork 并打开:${title || fork.title}\n\`${fork.id}\``, `🍴 Forked and bound: ${title || fork.title}\n\`${fork.id}\``));
|
|
980
|
+
}
|
|
981
|
+
async undoSession(scopeId, rawCount, locale) {
|
|
982
|
+
if (this.activeTurns.has(scopeId))
|
|
983
|
+
throw new Error(localize(locale, '当前回复仍在运行,请先 /interrupt。', 'A turn is running; use /interrupt first.'));
|
|
984
|
+
const session = await this.requireBoundSession(scopeId, locale);
|
|
985
|
+
const count = clampInt(rawCount, 1, 1, 20);
|
|
986
|
+
const rows = unwrap(await this.app.getClient().session.messages({
|
|
987
|
+
sessionID: session.id,
|
|
988
|
+
directory: session.directory,
|
|
989
|
+
limit: 200,
|
|
990
|
+
}), 'session.messages');
|
|
991
|
+
const boundary = session.revert?.messageID
|
|
992
|
+
? rows.findIndex((row) => row.info.id === session.revert?.messageID)
|
|
993
|
+
: rows.length;
|
|
994
|
+
const visible = boundary >= 0 ? rows.slice(0, boundary) : rows;
|
|
995
|
+
const users = visible.filter((row) => row.info.role === 'user');
|
|
996
|
+
const target = users.at(-count);
|
|
997
|
+
if (!target)
|
|
998
|
+
throw new Error(localize(locale, `没有可回退的 ${count} 轮消息。`, `There are not ${count} user turns to undo.`));
|
|
999
|
+
unwrap(await this.app.getClient().session.revert({
|
|
1000
|
+
sessionID: session.id,
|
|
1001
|
+
directory: session.directory,
|
|
1002
|
+
messageID: target.info.id,
|
|
1003
|
+
}), 'session.revert');
|
|
1004
|
+
await this.send(scopeId, localize(locale, `↩️ 已按 OpenCode 原生语义回退 ${count} 轮;文件快照与会话消息已一起恢复。使用 /redo 可重做。`, `↩️ Undid ${count} turn(s) with OpenCode's native revert, including file snapshots and messages. Use /redo to restore.`));
|
|
1005
|
+
}
|
|
1006
|
+
async redoSession(scopeId, locale) {
|
|
1007
|
+
if (this.activeTurns.has(scopeId))
|
|
1008
|
+
throw new Error(localize(locale, '当前回复仍在运行,请先 /interrupt。', 'A turn is running; use /interrupt first.'));
|
|
1009
|
+
const session = await this.requireBoundSession(scopeId, locale);
|
|
1010
|
+
const revertedMessageId = session.revert?.messageID;
|
|
1011
|
+
if (!revertedMessageId)
|
|
1012
|
+
throw new Error(localize(locale, '当前没有可重做的回退。', 'There is no reverted turn to redo.'));
|
|
1013
|
+
const rows = unwrap(await this.app.getClient().session.messages({
|
|
1014
|
+
sessionID: session.id,
|
|
1015
|
+
directory: session.directory,
|
|
1016
|
+
limit: 200,
|
|
1017
|
+
}), 'session.messages');
|
|
1018
|
+
const boundary = rows.findIndex((row) => row.info.id === revertedMessageId);
|
|
1019
|
+
const next = boundary >= 0 ? rows.slice(boundary + 1).find((row) => row.info.role === 'user') : undefined;
|
|
1020
|
+
if (next) {
|
|
1021
|
+
unwrap(await this.app.getClient().session.revert({
|
|
1022
|
+
sessionID: session.id,
|
|
1023
|
+
directory: session.directory,
|
|
1024
|
+
messageID: next.info.id,
|
|
1025
|
+
}), 'session.revert');
|
|
1026
|
+
}
|
|
1027
|
+
else {
|
|
1028
|
+
unwrap(await this.app.getClient().session.unrevert({ sessionID: session.id, directory: session.directory }), 'session.unrevert');
|
|
1029
|
+
}
|
|
1030
|
+
await this.send(scopeId, localize(locale, '↪️ 已重做一轮。', '↪️ Redid one turn.'));
|
|
1031
|
+
}
|
|
1032
|
+
async archiveBoundSession(scopeId, locale) {
|
|
1033
|
+
if (this.activeTurns.has(scopeId))
|
|
1034
|
+
throw new Error(localize(locale, '当前回复仍在运行,请先 /interrupt。', 'A turn is running; use /interrupt first.'));
|
|
1035
|
+
const session = await this.requireBoundSession(scopeId, locale);
|
|
1036
|
+
await this.archiveSession(scopeId, session, locale);
|
|
1037
|
+
}
|
|
1038
|
+
async archiveCachedSession(scopeId, rawIndex, locale) {
|
|
1039
|
+
const index = Number.parseInt(rawIndex, 10);
|
|
1040
|
+
const cached = Number.isFinite(index) ? this.store.getCachedThread(scopeId, index) : null;
|
|
1041
|
+
if (!cached || cached.archived)
|
|
1042
|
+
throw new Error(localize(locale, '用法:先 /threads,再 /thread_archive <编号>。', 'Use /threads, then /thread_archive <number>.'));
|
|
1043
|
+
const session = await this.resolveSessionTarget(scopeId, rawIndex);
|
|
1044
|
+
if (!session)
|
|
1045
|
+
throw new Error(localize(locale, '找不到该会话。', 'Session not found.'));
|
|
1046
|
+
if ([...this.activeTurns.values()].some((turn) => turn.sessionId === session.id)) {
|
|
1047
|
+
throw new Error(localize(locale, '该会话仍在运行,请先中断。', 'That session is still running; interrupt it first.'));
|
|
1048
|
+
}
|
|
1049
|
+
await this.archiveSession(scopeId, session, locale);
|
|
1050
|
+
}
|
|
1051
|
+
async archiveSession(scopeId, session, locale) {
|
|
1052
|
+
unwrap(await this.app.getClient().session.update({
|
|
1053
|
+
sessionID: session.id,
|
|
1054
|
+
directory: session.directory,
|
|
1055
|
+
time: { archived: Date.now() },
|
|
1056
|
+
}), 'session.update');
|
|
1057
|
+
if (this.store.getBinding(scopeId)?.threadId === session.id)
|
|
1058
|
+
this.store.clearBinding(scopeId);
|
|
1059
|
+
const watch = this.watchers.get(scopeId);
|
|
1060
|
+
if (watch?.sessionId === session.id) {
|
|
1061
|
+
if (watch.flushTimer)
|
|
1062
|
+
clearTimeout(watch.flushTimer);
|
|
1063
|
+
this.watchers.delete(scopeId);
|
|
1064
|
+
}
|
|
1065
|
+
await this.send(scopeId, localize(locale, `📦 已归档 \`${session.id}\`。`, `📦 Archived \`${session.id}\`.`));
|
|
1066
|
+
}
|
|
1067
|
+
async unarchiveSession(scopeId, rawIndex, locale) {
|
|
1068
|
+
const index = Number.parseInt(rawIndex, 10);
|
|
1069
|
+
const cached = Number.isFinite(index) ? this.store.getCachedThread(scopeId, index) : null;
|
|
1070
|
+
if (!cached?.archived)
|
|
1071
|
+
throw new Error(localize(locale, '用法:先 /threads archived,再 /unarchive <编号>。', 'Use /threads archived, then /unarchive <number>.'));
|
|
1072
|
+
const response = await this.app.getClient().session.get({
|
|
1073
|
+
sessionID: cached.threadId,
|
|
1074
|
+
...(cached.cwd ? { directory: cached.cwd } : {}),
|
|
1075
|
+
});
|
|
1076
|
+
const session = unwrap(response, 'session.get');
|
|
1077
|
+
const restored = unwrap(await this.app.getClient().session.update({
|
|
1078
|
+
sessionID: session.id,
|
|
1079
|
+
directory: session.directory,
|
|
1080
|
+
time: { archived: 0 },
|
|
1081
|
+
}), 'session.update');
|
|
1082
|
+
this.store.setBinding(scopeId, restored.id, restored.directory);
|
|
1083
|
+
await this.send(scopeId, localize(locale, `📤 已恢复并打开:${restored.title}\n\`${restored.id}\``, `📤 Restored and bound: ${restored.title}\n\`${restored.id}\``));
|
|
1084
|
+
}
|
|
1085
|
+
async showDiff(scopeId, locale) {
|
|
1086
|
+
const session = await this.requireBoundSession(scopeId, locale);
|
|
1087
|
+
const diffs = unwrap(await this.app.getClient().session.diff({ sessionID: session.id, directory: session.directory }), 'session.diff');
|
|
1088
|
+
if (diffs.length === 0) {
|
|
1089
|
+
await this.send(scopeId, localize(locale, '当前会话没有文件变更。', 'No file changes in this session.'));
|
|
1090
|
+
return;
|
|
1091
|
+
}
|
|
1092
|
+
const lines = [localize(locale, '📝 会话变更:', '📝 Session changes:'), ''];
|
|
1093
|
+
for (const diff of diffs.slice(0, 25))
|
|
1094
|
+
lines.push(`\`${diff.file ?? '?'}\` · +${diff.additions} / -${diff.deletions}${diff.status ? ` · ${diff.status}` : ''}`);
|
|
1095
|
+
await this.send(scopeId, lines.join('\n'));
|
|
1096
|
+
}
|
|
1097
|
+
async showWhere(scopeId, locale) {
|
|
1098
|
+
const session = await this.requireBoundSession(scopeId, locale);
|
|
1099
|
+
await this.send(scopeId, `${localize(locale, '会话', 'Session')}: \`${session.id}\`\n${localize(locale, '目录', 'Directory')}: \`${session.directory}\``);
|
|
1100
|
+
}
|
|
1101
|
+
async findFiles(scopeId, query, locale) {
|
|
1102
|
+
if (!query)
|
|
1103
|
+
throw new Error(localize(locale, '用法:/files <关键词>', 'Usage: /files <query>'));
|
|
1104
|
+
const cwd = this.store.getBinding(scopeId)?.cwd ?? this.config.defaultCwd;
|
|
1105
|
+
const files = unwrap(await this.app.getClient().find.files({ directory: cwd, query, limit: 30 }), 'find.files');
|
|
1106
|
+
await this.send(scopeId, files.length
|
|
1107
|
+
? [localize(locale, `🔍 “${query}” 的结果:`, `🔍 Results for “${query}”:`), '', ...files.map((file) => `\`${file}\``)].join('\n')
|
|
1108
|
+
: localize(locale, '没有匹配文件。', 'No matching files.'));
|
|
1109
|
+
}
|
|
1110
|
+
async compactSession(scopeId, locale) {
|
|
1111
|
+
const session = await this.requireBoundSession(scopeId, locale);
|
|
1112
|
+
const selected = await this.effectiveModel(scopeId, session);
|
|
1113
|
+
if (!selected)
|
|
1114
|
+
throw new Error(localize(locale, '没有可用于压缩的模型。', 'No model is available for compaction.'));
|
|
1115
|
+
unwrap(await this.app.getClient().session.summarize({
|
|
1116
|
+
sessionID: session.id,
|
|
1117
|
+
directory: session.directory,
|
|
1118
|
+
providerID: selected.providerId,
|
|
1119
|
+
modelID: selected.modelId,
|
|
1120
|
+
}), 'session.summarize');
|
|
1121
|
+
await this.send(scopeId, localize(locale, '✅ 上下文压缩已完成。', '✅ Context compaction completed.'));
|
|
1122
|
+
}
|
|
1123
|
+
async showLoaded(scopeId, locale) {
|
|
1124
|
+
const sessions = unwrap(await this.app.getClient().experimental.session.list({ limit: 100 }), 'session.list');
|
|
1125
|
+
const statuses = await this.statusesForSessions(sessions);
|
|
1126
|
+
const entries = Object.entries(statuses);
|
|
1127
|
+
if (entries.length === 0) {
|
|
1128
|
+
await this.send(scopeId, localize(locale, '当前没有已加载会话。', 'No loaded sessions.'));
|
|
1129
|
+
return;
|
|
1130
|
+
}
|
|
1131
|
+
const lines = [localize(locale, '已加载会话:', 'Loaded sessions:'), ''];
|
|
1132
|
+
for (const [id, status] of entries)
|
|
1133
|
+
lines.push(`${status.type === 'busy' ? '⏳' : status.type === 'retry' ? '🔁' : '○'} \`${shortId(id)}\` · ${status.type}`);
|
|
1134
|
+
await this.send(scopeId, lines.join('\n'));
|
|
1135
|
+
}
|
|
1136
|
+
async statusesForSessions(sessions) {
|
|
1137
|
+
const directories = [...new Set(sessions.map((session) => session.directory))];
|
|
1138
|
+
const responses = await Promise.all(directories.map(async (directory) => {
|
|
1139
|
+
const response = await this.app.getClient().session.status({ directory });
|
|
1140
|
+
return response.error ? {} : response.data ?? {};
|
|
1141
|
+
}));
|
|
1142
|
+
return Object.assign({}, ...responses);
|
|
1143
|
+
}
|
|
1144
|
+
async showSkills(scopeId, locale) {
|
|
1145
|
+
const cwd = this.store.getBinding(scopeId)?.cwd ?? this.config.defaultCwd;
|
|
1146
|
+
const skills = unwrap(await this.app.getClient().app.skills({ directory: cwd }), 'skill.list');
|
|
1147
|
+
if (skills.length === 0) {
|
|
1148
|
+
await this.send(scopeId, localize(locale, '没有已加载 Skill。', 'No loaded skills.'));
|
|
1149
|
+
return;
|
|
1150
|
+
}
|
|
1151
|
+
const lines = [localize(locale, 'OpenCode Skills:', 'OpenCode skills:'), ''];
|
|
1152
|
+
for (const skill of skills.slice(0, 30))
|
|
1153
|
+
lines.push(`• \`${skill.name}\` — ${clip(skill.description ?? '', 100)}`);
|
|
1154
|
+
await this.send(scopeId, lines.join('\n'));
|
|
1155
|
+
}
|
|
1156
|
+
async showMcp(scopeId, locale, appsAlias = false) {
|
|
1157
|
+
const cwd = this.store.getBinding(scopeId)?.cwd ?? this.config.defaultCwd;
|
|
1158
|
+
const servers = unwrap(await this.app.getClient().mcp.status({ directory: cwd }), 'mcp.status');
|
|
1159
|
+
const entries = Object.entries(servers);
|
|
1160
|
+
if (entries.length === 0) {
|
|
1161
|
+
await this.send(scopeId, appsAlias
|
|
1162
|
+
? localize(locale, 'OpenCode 用 MCP server 提供外部应用能力;当前没有配置 MCP server。', 'OpenCode exposes external app capabilities through MCP servers; none are configured.')
|
|
1163
|
+
: localize(locale, '没有配置 MCP server。', 'No MCP servers configured.'));
|
|
1164
|
+
return;
|
|
1165
|
+
}
|
|
1166
|
+
const icon = (status) => status === 'connected' ? '✅' : status === 'failed' ? '❌' : status === 'needs_auth' ? '🔑' : '⚪';
|
|
1167
|
+
await this.send(scopeId, [appsAlias
|
|
1168
|
+
? localize(locale, 'OpenCode Apps(MCP server):', 'OpenCode apps (MCP servers):')
|
|
1169
|
+
: localize(locale, 'MCP 状态:', 'MCP status:'), '', ...entries.map(([name, value]) => `${icon(value.status)} \`${name}\` · ${value.status}${'error' in value ? ` · ${value.error}` : ''}`)].join('\n'));
|
|
1170
|
+
}
|
|
1171
|
+
async showProviders(scopeId, locale) {
|
|
1172
|
+
const cwd = this.store.getBinding(scopeId)?.cwd ?? this.config.defaultCwd;
|
|
1173
|
+
const data = unwrap(await this.app.getClient().provider.list({ directory: cwd }), 'provider.list');
|
|
1174
|
+
const connected = new Set(data.connected);
|
|
1175
|
+
const lines = [localize(locale, '模型 Provider:', 'Model providers:'), ''];
|
|
1176
|
+
for (const provider of data.all)
|
|
1177
|
+
lines.push(`${connected.has(provider.id) ? '✅' : '○'} \`${provider.id}\` · ${provider.name} · ${Object.keys(provider.models).length} models`);
|
|
1178
|
+
await this.send(scopeId, lines.join('\n'));
|
|
1179
|
+
}
|
|
1180
|
+
async showAuth(scopeId, locale) {
|
|
1181
|
+
const cwd = this.store.getBinding(scopeId)?.cwd ?? this.config.defaultCwd;
|
|
1182
|
+
const [providerData, authMethods] = await Promise.all([
|
|
1183
|
+
this.app.getClient().provider.list({ directory: cwd }),
|
|
1184
|
+
this.app.getClient().provider.auth({ directory: cwd }),
|
|
1185
|
+
]);
|
|
1186
|
+
const providers = unwrap(providerData, 'provider.list');
|
|
1187
|
+
const methods = unwrap(authMethods, 'provider.auth');
|
|
1188
|
+
const connected = new Set(providers.connected);
|
|
1189
|
+
const lines = [localize(locale, '🔐 OpenCode Provider 认证:', '🔐 OpenCode provider authentication:'), ''];
|
|
1190
|
+
for (const provider of providers.all.filter((item) => connected.has(item.id))) {
|
|
1191
|
+
const available = methods[provider.id] ?? [];
|
|
1192
|
+
lines.push(`✅ ${provider.name} · \`${provider.id}\`${available.length ? ` · ${available.map((method) => method.label).join(' / ')}` : ''}`);
|
|
1193
|
+
}
|
|
1194
|
+
if (providers.connected.length === 0)
|
|
1195
|
+
lines.push(localize(locale, '当前没有已连接 Provider。', 'No providers are connected.'));
|
|
1196
|
+
lines.push('', localize(locale, 'OpenCode serve 只公开认证方法和 OAuth 端点,没有 Codex 设备登录/账号切换面板。登录或切换请在终端运行:opencode auth', 'OpenCode serve exposes auth methods and OAuth endpoints, but no Codex-style device-login/account-switch panel. Run opencode auth in a terminal to sign in or switch.'));
|
|
1197
|
+
await this.send(scopeId, lines.join('\n'));
|
|
1198
|
+
}
|
|
1199
|
+
async showPlugins(scopeId, locale, hooksAlias) {
|
|
1200
|
+
const cwd = this.store.getBinding(scopeId)?.cwd ?? this.config.defaultCwd;
|
|
1201
|
+
const config = unwrap(await this.app.getClient().config.get({ directory: cwd }), 'config.get');
|
|
1202
|
+
const plugins = (config.plugin ?? []).map((entry) => Array.isArray(entry) ? entry[0] : entry);
|
|
1203
|
+
const title = hooksAlias
|
|
1204
|
+
? localize(locale, '🪝 OpenCode Hooks(由 Plugins 提供):', '🪝 OpenCode hooks (provided by plugins):')
|
|
1205
|
+
: localize(locale, '🧩 OpenCode Plugins:', '🧩 OpenCode plugins:');
|
|
1206
|
+
await this.send(scopeId, plugins.length > 0
|
|
1207
|
+
? [title, '', ...plugins.map((plugin) => `• \`${plugin}\``), '', hooksAlias
|
|
1208
|
+
? localize(locale, 'OpenCode 没有独立 hooks 注册表;hook 生命周期由这些 plugin 管理。', 'OpenCode has no separate hooks registry; these plugins own hook lifecycles.')
|
|
1209
|
+
: localize(locale, 'Plugin 配置来自当前目录的有效 OpenCode config。', 'Plugin entries come from the effective OpenCode config for this directory.')].join('\n')
|
|
1210
|
+
: [title, '', localize(locale, '当前有效配置没有 plugin。', 'No plugins are present in the effective config.')].join('\n'));
|
|
1211
|
+
}
|
|
1212
|
+
async showFeatures(scopeId, locale) {
|
|
1213
|
+
const cwd = this.store.getBinding(scopeId)?.cwd ?? this.config.defaultCwd;
|
|
1214
|
+
const capabilities = unwrap(await this.app.getClient().experimental.capabilities.get({ directory: cwd }), 'experimental.capabilities.get');
|
|
1215
|
+
const entries = Object.entries(capabilities);
|
|
1216
|
+
await this.send(scopeId, [localize(locale, '🧪 OpenCode 实验能力:', '🧪 OpenCode experimental capabilities:'), '',
|
|
1217
|
+
...(entries.length ? entries.map(([name, value]) => `${value ? '✅' : '○'} \`${name}\` · ${String(value)}`) : [localize(locale, '服务端没有报告实验能力。', 'The server reported no experimental capabilities.')]),
|
|
1218
|
+
].join('\n'));
|
|
1219
|
+
}
|
|
1220
|
+
async showConfig(scopeId, locale) {
|
|
1221
|
+
const cwd = this.store.getBinding(scopeId)?.cwd ?? this.config.defaultCwd;
|
|
1222
|
+
const config = unwrap(await this.app.getClient().config.get({ directory: cwd }), 'config.get');
|
|
1223
|
+
const keys = ['model', 'small_model', 'default_agent', 'share', 'autoupdate', 'username'];
|
|
1224
|
+
const lines = [localize(locale, '⚙️ OpenCode 有效配置(敏感字段已省略):', '⚙️ Effective OpenCode config (sensitive fields omitted):'), ''];
|
|
1225
|
+
for (const key of keys) {
|
|
1226
|
+
const value = config[key];
|
|
1227
|
+
if (value !== undefined)
|
|
1228
|
+
lines.push(`\`${key}\`: ${typeof value === 'object' ? '[configured]' : String(value)}`);
|
|
1229
|
+
}
|
|
1230
|
+
lines.push(`\`mcp\`: ${Object.keys(config.mcp ?? {}).length}`, `\`agent\`: ${Object.keys(config.agent ?? {}).length}`);
|
|
1231
|
+
await this.send(scopeId, lines.join('\n'));
|
|
1232
|
+
}
|
|
1233
|
+
async runReview(event, argumentsText, locale) {
|
|
1234
|
+
if (this.activeTurns.has(event.scopeId)) {
|
|
1235
|
+
throw new Error(localize(locale, '当前回复仍在运行;请等待、/queue,或先 /interrupt。', 'A turn is already running; wait, use /queue, or /interrupt first.'));
|
|
1236
|
+
}
|
|
1237
|
+
const session = await this.requireBoundSession(event.scopeId, locale);
|
|
1238
|
+
const settings = this.store.getChatSettings(event.scopeId);
|
|
1239
|
+
const prefs = readPrefs(settings);
|
|
1240
|
+
const model = await this.effectiveModel(event.scopeId, session);
|
|
1241
|
+
const agent = settings?.collaborationMode === 'plan' ? 'plan' : prefs.agent ?? 'build';
|
|
1242
|
+
const tracked = this.startTrackedTurn(event.scopeId, session.id, session.directory);
|
|
1243
|
+
await this.messaging.sendTypingInScope(event.scopeId);
|
|
1244
|
+
const request = this.app.getClient().session.command({
|
|
1245
|
+
sessionID: session.id,
|
|
1246
|
+
directory: session.directory,
|
|
1247
|
+
command: 'review',
|
|
1248
|
+
arguments: argumentsText,
|
|
1249
|
+
agent,
|
|
1250
|
+
...(model ? { model: `${model.providerId}/${model.modelId}` } : {}),
|
|
1251
|
+
...(prefs.variant ? { variant: prefs.variant } : {}),
|
|
1252
|
+
});
|
|
1253
|
+
void request.then(async (response) => {
|
|
1254
|
+
if (!response.error)
|
|
1255
|
+
return;
|
|
1256
|
+
if (this.activeTurns.get(event.scopeId) === tracked)
|
|
1257
|
+
this.activeTurns.delete(event.scopeId);
|
|
1258
|
+
await this.reportError(event.scopeId, new Error(formatSdkError(response.error)));
|
|
1259
|
+
}, async (error) => {
|
|
1260
|
+
if (this.activeTurns.get(event.scopeId) === tracked)
|
|
1261
|
+
this.activeTurns.delete(event.scopeId);
|
|
1262
|
+
await this.reportError(event.scopeId, error);
|
|
1263
|
+
});
|
|
1264
|
+
if (settings?.collaborationMode === 'plan')
|
|
1265
|
+
this.store.setChatCollaborationMode(event.scopeId, 'default');
|
|
1266
|
+
this.app.watchSessionUntilIdle(session.id, session.directory);
|
|
1267
|
+
}
|
|
1268
|
+
async showRichDemo(scopeId, locale) {
|
|
1269
|
+
const markdown = localize(locale, '## FoxClaw 富文本测试\n\n- **粗体**、`代码` 与 [链接](https://opencode.ai)\n- OpenCode 流式回复完成后也会使用同一套富文本渲染。', '## FoxClaw rich-text test\n\n- **Bold**, `code`, and a [link](https://opencode.ai)\n- Completed OpenCode streams use the same rich renderer.');
|
|
1270
|
+
await this.sendFinalChunk(scopeId, markdown);
|
|
1271
|
+
}
|
|
1272
|
+
async handleVoiceCommand(scopeId, args, locale) {
|
|
1273
|
+
if (args[0]?.toLowerCase() === 'file' || args[0]?.toLowerCase() === 'send') {
|
|
1274
|
+
const fileArg = args[1]?.trim();
|
|
1275
|
+
if (!fileArg)
|
|
1276
|
+
throw new Error(localize(locale, '用法:/voice file /path/to/audio.ogg [说明]', 'Usage: /voice file /path/to/audio.ogg [caption]'));
|
|
1277
|
+
const filePath = path.resolve(this.config.defaultCwd, fileArg);
|
|
1278
|
+
const contentType = telegramVoiceContentType(filePath);
|
|
1279
|
+
if (!contentType)
|
|
1280
|
+
throw new Error(localize(locale, `只支持 Telegram voice 音频格式:${TELEGRAM_VOICE_SUPPORTED_EXTENSIONS}。`, `Supported Telegram voice formats: ${TELEGRAM_VOICE_SUPPORTED_EXTENSIONS}.`));
|
|
1281
|
+
const stat = await fs.stat(filePath).catch(() => null);
|
|
1282
|
+
if (!stat?.isFile())
|
|
1283
|
+
throw new Error(localize(locale, `找不到音频文件:${filePath}`, `Audio file not found: ${filePath}`));
|
|
1284
|
+
if (stat.size > TELEGRAM_VOICE_MAX_BYTES)
|
|
1285
|
+
throw new Error(localize(locale, 'Telegram voice 文件不能超过 50MB。', 'Telegram voice files must be 50MB or smaller.'));
|
|
1286
|
+
const contents = await fs.readFile(filePath);
|
|
1287
|
+
const caption = args.slice(2).join(' ').trim() || localize(locale, 'FoxClaw 语音文件', 'FoxClaw voice file');
|
|
1288
|
+
await this.messaging.sendVoice(scopeId, path.basename(filePath), contents, caption, contentType);
|
|
1289
|
+
return;
|
|
1290
|
+
}
|
|
1291
|
+
const raw = args.join(' ').trim();
|
|
1292
|
+
const text = raw.toLowerCase() === 'last' ? this.latestVoiceText.get(scopeId) ?? '' : raw;
|
|
1293
|
+
if (!text)
|
|
1294
|
+
throw new Error(localize(locale, '用法:/voice <文本>、/voice last 或 /voice file <路径>。', 'Usage: /voice <text>, /voice last, or /voice file <path>.'));
|
|
1295
|
+
if (!this.config.voiceTtsEnabled)
|
|
1296
|
+
throw new Error(localize(locale, '语音服务未启用。', 'Voice TTS is not enabled.'));
|
|
1297
|
+
try {
|
|
1298
|
+
const voice = await synthesizeTelegramVoice(text, this.config);
|
|
1299
|
+
await this.messaging.sendVoice(scopeId, voice.filename, voice.contents, localize(locale, 'FoxClaw 总结语音', 'FoxClaw voice summary'), voice.contentType);
|
|
1300
|
+
}
|
|
1301
|
+
catch (error) {
|
|
1302
|
+
throw new Error(localize(locale, `语音生成失败:${error instanceof Error ? error.message : String(error)}`, `Voice generation failed: ${error instanceof Error ? error.message : String(error)}`));
|
|
1303
|
+
}
|
|
1304
|
+
}
|
|
1305
|
+
async showFastUnsupported(scopeId, locale) {
|
|
1306
|
+
await this.send(scopeId, localize(locale, '⚡ OpenCode serve 没有 Codex Fast service tier 的等价 API。可在 /setup 中选择当前模型提供的 variant;FoxClaw 不会把某个 variant 冒充 Fast。', '⚡ OpenCode serve has no API equivalent to the Codex Fast service tier. Use /setup for model variants; FoxClaw will not relabel a variant as Fast.'));
|
|
1307
|
+
}
|
|
1308
|
+
async abort(scopeId, locale) {
|
|
1309
|
+
const turn = this.activeTurns.get(scopeId);
|
|
1310
|
+
const binding = this.store.getBinding(scopeId);
|
|
1311
|
+
const sessionId = turn?.sessionId ?? binding?.threadId;
|
|
1312
|
+
if (!sessionId) {
|
|
1313
|
+
await this.send(scopeId, localize(locale, '没有进行中的回复。', 'No active turn.'));
|
|
1314
|
+
return;
|
|
1315
|
+
}
|
|
1316
|
+
const directory = turn?.cwd ?? binding?.cwd ?? null;
|
|
1317
|
+
const response = await this.app.getClient().session.abort({
|
|
1318
|
+
sessionID: sessionId,
|
|
1319
|
+
...(directory ? { directory } : {}),
|
|
1320
|
+
});
|
|
1321
|
+
if (response.error)
|
|
1322
|
+
throw new Error(formatSdkError(response.error));
|
|
1323
|
+
await this.finishSession(sessionId);
|
|
1324
|
+
await this.send(scopeId, localize(locale, '⏹️ 已中断。', '⏹️ Turn aborted.'));
|
|
1325
|
+
}
|
|
1326
|
+
async requireBoundSession(scopeId, locale) {
|
|
1327
|
+
const session = await this.resolveSessionTarget(scopeId, '');
|
|
1328
|
+
if (!session)
|
|
1329
|
+
throw new Error(localize(locale, '当前没有有效会话,先发送消息或使用 /new。', 'No valid bound session; send a message or use /new.'));
|
|
1330
|
+
return session;
|
|
1331
|
+
}
|
|
1332
|
+
async effectiveModel(scopeId, session) {
|
|
1333
|
+
const configured = this.store.getChatSettings(scopeId)?.model;
|
|
1334
|
+
if (configured)
|
|
1335
|
+
return parseStoredModel(configured);
|
|
1336
|
+
if (session.model)
|
|
1337
|
+
return { providerId: session.model.providerID, modelId: session.model.id };
|
|
1338
|
+
const data = unwrap(await this.app.getClient().provider.list({ directory: session.directory }), 'provider.list');
|
|
1339
|
+
const providerId = data.connected[0];
|
|
1340
|
+
if (!providerId)
|
|
1341
|
+
return null;
|
|
1342
|
+
const modelId = data.default[providerId] ?? Object.keys(data.all.find((provider) => provider.id === providerId)?.models ?? {})[0];
|
|
1343
|
+
return modelId ? { providerId, modelId } : null;
|
|
1344
|
+
}
|
|
1345
|
+
async handleAppEvent(event) {
|
|
1346
|
+
switch (event.kind) {
|
|
1347
|
+
case 'text':
|
|
1348
|
+
this.handleTextEvent(event);
|
|
1349
|
+
return;
|
|
1350
|
+
case 'tool':
|
|
1351
|
+
this.handleToolEvent(event);
|
|
1352
|
+
return;
|
|
1353
|
+
case 'permission':
|
|
1354
|
+
await this.handlePermission(event.request);
|
|
1355
|
+
return;
|
|
1356
|
+
case 'permissionResolved':
|
|
1357
|
+
this.resolvePermission(event.requestId);
|
|
1358
|
+
return;
|
|
1359
|
+
case 'question':
|
|
1360
|
+
await this.handleQuestion(event.request);
|
|
1361
|
+
return;
|
|
1362
|
+
case 'questionResolved':
|
|
1363
|
+
this.resolveQuestion(event.requestId);
|
|
1364
|
+
return;
|
|
1365
|
+
case 'idle':
|
|
1366
|
+
await this.finishSession(event.sessionId);
|
|
1367
|
+
return;
|
|
1368
|
+
case 'status':
|
|
1369
|
+
if (event.status.type === 'idle')
|
|
1370
|
+
await this.finishSession(event.sessionId);
|
|
1371
|
+
return;
|
|
1372
|
+
case 'error':
|
|
1373
|
+
if (event.sessionId) {
|
|
1374
|
+
for (const scopeId of this.scopesForSession(event.sessionId))
|
|
1375
|
+
await this.send(scopeId, `⚠️ ${event.message}`);
|
|
1376
|
+
await this.finishSession(event.sessionId);
|
|
1377
|
+
}
|
|
1378
|
+
}
|
|
1379
|
+
}
|
|
1380
|
+
async cleanupAfterDisconnect() {
|
|
1381
|
+
const turns = [...this.activeTurns];
|
|
1382
|
+
this.activeTurns.clear();
|
|
1383
|
+
for (const [scopeId, turn] of turns) {
|
|
1384
|
+
this.clearTurnTimers(turn);
|
|
1385
|
+
await this.flushTurn(scopeId, turn, true).catch((error) => this.reportError(scopeId, error));
|
|
1386
|
+
if (turn.toolMessageId !== null) {
|
|
1387
|
+
await this.messaging.editPlain(scopeId, turn.toolMessageId, '⚠️ OpenCode serve disconnected').catch(() => { });
|
|
1388
|
+
}
|
|
1389
|
+
const locale = this.localeForScope(scopeId);
|
|
1390
|
+
await this.send(scopeId, localize(locale, '⚠️ OpenCode serve 已断开,FoxClaw 正在重连。', '⚠️ OpenCode serve disconnected; FoxClaw is reconnecting.')).catch(() => { });
|
|
1391
|
+
}
|
|
1392
|
+
}
|
|
1393
|
+
async recoverAfterReconnect(cleanup) {
|
|
1394
|
+
await cleanup;
|
|
1395
|
+
await this.app.recoverPendingRequests(this.store.listBindings()
|
|
1396
|
+
.filter((binding) => this.isOwnScope(binding.chatId))
|
|
1397
|
+
.flatMap((binding) => binding.cwd ? [binding.cwd] : []));
|
|
1398
|
+
for (const [scopeId, queue] of [...this.queuedPrompts]) {
|
|
1399
|
+
if (this.activeTurns.has(scopeId))
|
|
1400
|
+
continue;
|
|
1401
|
+
const next = queue.shift();
|
|
1402
|
+
if (!next) {
|
|
1403
|
+
this.queuedPrompts.delete(scopeId);
|
|
1404
|
+
continue;
|
|
1405
|
+
}
|
|
1406
|
+
if (queue.length === 0)
|
|
1407
|
+
this.queuedPrompts.delete(scopeId);
|
|
1408
|
+
try {
|
|
1409
|
+
await this.dispatchPrompt(next.event, next.text, next.locale);
|
|
1410
|
+
}
|
|
1411
|
+
catch (error) {
|
|
1412
|
+
const pending = this.queuedPrompts.get(scopeId) ?? [];
|
|
1413
|
+
pending.unshift(next);
|
|
1414
|
+
this.queuedPrompts.set(scopeId, pending);
|
|
1415
|
+
await this.reportError(scopeId, error);
|
|
1416
|
+
}
|
|
1417
|
+
}
|
|
1418
|
+
}
|
|
1419
|
+
scopesForSession(sessionId) {
|
|
1420
|
+
const scopes = new Set(this.store.findAllChatIdsByThreadId(sessionId).filter((scopeId) => this.isOwnScope(scopeId)));
|
|
1421
|
+
for (const [scopeId, turn] of this.activeTurns)
|
|
1422
|
+
if (turn.sessionId === sessionId)
|
|
1423
|
+
scopes.add(scopeId);
|
|
1424
|
+
for (const [scopeId, watch] of this.watchers)
|
|
1425
|
+
if (watch.sessionId === sessionId)
|
|
1426
|
+
scopes.add(scopeId);
|
|
1427
|
+
return [...scopes];
|
|
1428
|
+
}
|
|
1429
|
+
isOwnScope(scopeId) {
|
|
1430
|
+
return Boolean(this.bot.identity && scopeId.startsWith(`telegram:${this.bot.identity}:`));
|
|
1431
|
+
}
|
|
1432
|
+
cwdForSession(scopeId, sessionId) {
|
|
1433
|
+
const turn = this.activeTurns.get(scopeId);
|
|
1434
|
+
if (turn?.sessionId === sessionId)
|
|
1435
|
+
return turn.cwd;
|
|
1436
|
+
const watch = this.watchers.get(scopeId);
|
|
1437
|
+
if (watch?.sessionId === sessionId)
|
|
1438
|
+
return watch.cwd;
|
|
1439
|
+
const binding = this.store.getBinding(scopeId);
|
|
1440
|
+
if (binding?.threadId === sessionId && binding.cwd)
|
|
1441
|
+
return binding.cwd;
|
|
1442
|
+
return this.config.defaultCwd;
|
|
1443
|
+
}
|
|
1444
|
+
handleTextEvent(event) {
|
|
1445
|
+
for (const [scopeId, turn] of this.activeTurns) {
|
|
1446
|
+
if (turn.sessionId !== event.sessionId)
|
|
1447
|
+
continue;
|
|
1448
|
+
turn.parts.set(`${event.messageId}:${event.partId}`, event.text);
|
|
1449
|
+
this.scheduleTurnFlush(scopeId, turn);
|
|
1450
|
+
}
|
|
1451
|
+
for (const [scopeId, watch] of this.watchers) {
|
|
1452
|
+
if (watch.sessionId !== event.sessionId)
|
|
1453
|
+
continue;
|
|
1454
|
+
watch.parts.set(`${event.messageId}:${event.partId}`, event.text);
|
|
1455
|
+
this.scheduleWatchFlush(scopeId, watch);
|
|
1456
|
+
}
|
|
1457
|
+
}
|
|
1458
|
+
handleToolEvent(event) {
|
|
1459
|
+
for (const [scopeId, turn] of this.activeTurns) {
|
|
1460
|
+
if (turn.sessionId !== event.sessionId)
|
|
1461
|
+
continue;
|
|
1462
|
+
const icon = event.status === 'completed' ? '✅' : event.status === 'error' ? '❌' : '🔧';
|
|
1463
|
+
turn.toolLines.set(event.callId, `${icon} ${clip(event.title ?? event.tool, 120)}${event.error ? ` — ${clip(event.error, 160)}` : ''}`);
|
|
1464
|
+
this.scheduleToolFlush(scopeId, turn);
|
|
1465
|
+
}
|
|
1466
|
+
}
|
|
1467
|
+
scheduleTurnFlush(scopeId, turn) {
|
|
1468
|
+
if (turn.flushTimer)
|
|
1469
|
+
return;
|
|
1470
|
+
turn.flushTimer = setTimeout(() => {
|
|
1471
|
+
turn.flushTimer = null;
|
|
1472
|
+
void this.flushTurn(scopeId, turn, false).catch((error) => this.reportError(scopeId, error));
|
|
1473
|
+
}, Math.max(0, STREAM_THROTTLE_MS - (Date.now() - turn.lastFlush)));
|
|
1474
|
+
}
|
|
1475
|
+
async flushTurn(scopeId, turn, final) {
|
|
1476
|
+
const task = turn.flushPromise.then(() => this.flushTurnNow(scopeId, turn, final));
|
|
1477
|
+
turn.flushPromise = task.catch(() => { });
|
|
1478
|
+
return task;
|
|
1479
|
+
}
|
|
1480
|
+
async flushTurnNow(scopeId, turn, final) {
|
|
1481
|
+
const text = [...turn.parts.values()].join('');
|
|
1482
|
+
if (!text.trim())
|
|
1483
|
+
return;
|
|
1484
|
+
const chunks = chunkTelegramStreamMessage(text);
|
|
1485
|
+
for (let index = 0; index < chunks.length; index++) {
|
|
1486
|
+
const chunk = chunks[index];
|
|
1487
|
+
const messageId = turn.messageIds[index];
|
|
1488
|
+
if (messageId === undefined) {
|
|
1489
|
+
turn.messageIds.push(final
|
|
1490
|
+
? await this.sendFinalChunk(scopeId, chunk)
|
|
1491
|
+
: await this.messaging.sendPlain(scopeId, chunk));
|
|
1492
|
+
}
|
|
1493
|
+
else if (turn.renderedChunks[index] !== chunk) {
|
|
1494
|
+
if (final)
|
|
1495
|
+
await this.editFinalChunk(scopeId, messageId, chunk);
|
|
1496
|
+
else
|
|
1497
|
+
await this.messaging.editPlain(scopeId, messageId, chunk);
|
|
1498
|
+
}
|
|
1499
|
+
else if (final) {
|
|
1500
|
+
await this.editFinalChunk(scopeId, messageId, chunk);
|
|
1501
|
+
}
|
|
1502
|
+
}
|
|
1503
|
+
turn.renderedChunks = chunks;
|
|
1504
|
+
turn.lastFlush = Date.now();
|
|
1505
|
+
}
|
|
1506
|
+
scheduleToolFlush(scopeId, turn) {
|
|
1507
|
+
if (turn.toolTimer)
|
|
1508
|
+
return;
|
|
1509
|
+
turn.toolTimer = setTimeout(() => {
|
|
1510
|
+
turn.toolTimer = null;
|
|
1511
|
+
void this.flushTools(scopeId, turn).catch((error) => this.reportError(scopeId, error));
|
|
1512
|
+
}, TOOL_THROTTLE_MS);
|
|
1513
|
+
}
|
|
1514
|
+
async flushTools(scopeId, turn) {
|
|
1515
|
+
const task = turn.toolPromise.then(() => this.flushToolsNow(scopeId, turn));
|
|
1516
|
+
turn.toolPromise = task.catch(() => { });
|
|
1517
|
+
return task;
|
|
1518
|
+
}
|
|
1519
|
+
async flushToolsNow(scopeId, turn) {
|
|
1520
|
+
const lines = [...turn.toolLines.values()].slice(-8);
|
|
1521
|
+
if (lines.length === 0)
|
|
1522
|
+
return;
|
|
1523
|
+
const text = `${lines.join('\n')}\n\n⏳ OpenCode…`;
|
|
1524
|
+
if (turn.toolMessageId === null)
|
|
1525
|
+
turn.toolMessageId = await this.messaging.sendPlain(scopeId, text);
|
|
1526
|
+
else
|
|
1527
|
+
await this.messaging.editPlain(scopeId, turn.toolMessageId, text);
|
|
1528
|
+
}
|
|
1529
|
+
scheduleWatchFlush(scopeId, watch) {
|
|
1530
|
+
if (watch.flushTimer)
|
|
1531
|
+
return;
|
|
1532
|
+
watch.flushTimer = setTimeout(() => {
|
|
1533
|
+
watch.flushTimer = null;
|
|
1534
|
+
void this.flushWatch(scopeId, watch, false).catch((error) => this.reportError(scopeId, error));
|
|
1535
|
+
}, Math.max(0, STREAM_THROTTLE_MS - (Date.now() - watch.lastFlush)));
|
|
1536
|
+
}
|
|
1537
|
+
async flushWatch(scopeId, watch, final) {
|
|
1538
|
+
const task = watch.flushPromise.then(() => this.flushWatchNow(scopeId, watch, final));
|
|
1539
|
+
watch.flushPromise = task.catch(() => { });
|
|
1540
|
+
return task;
|
|
1541
|
+
}
|
|
1542
|
+
async flushWatchNow(scopeId, watch, final) {
|
|
1543
|
+
const text = [...watch.parts.values()].join('');
|
|
1544
|
+
if (!text.trim())
|
|
1545
|
+
return;
|
|
1546
|
+
const chunks = chunkTelegramStreamMessage(text);
|
|
1547
|
+
for (let index = 0; index < chunks.length; index++) {
|
|
1548
|
+
const chunk = chunks[index];
|
|
1549
|
+
const id = watch.messageIds[index];
|
|
1550
|
+
if (id === undefined)
|
|
1551
|
+
watch.messageIds.push(final ? await this.sendFinalChunk(scopeId, chunk) : await this.messaging.sendPlain(scopeId, chunk));
|
|
1552
|
+
else if (watch.renderedChunks[index] !== chunk || final) {
|
|
1553
|
+
if (final)
|
|
1554
|
+
await this.editFinalChunk(scopeId, id, chunk);
|
|
1555
|
+
else
|
|
1556
|
+
await this.messaging.editPlain(scopeId, id, chunk);
|
|
1557
|
+
}
|
|
1558
|
+
}
|
|
1559
|
+
watch.renderedChunks = chunks;
|
|
1560
|
+
watch.lastFlush = Date.now();
|
|
1561
|
+
}
|
|
1562
|
+
async finishSession(sessionId) {
|
|
1563
|
+
const current = this.finishingSessions.get(sessionId);
|
|
1564
|
+
if (current)
|
|
1565
|
+
return current;
|
|
1566
|
+
const task = this.finishSessionNow(sessionId);
|
|
1567
|
+
const tracked = task.finally(() => {
|
|
1568
|
+
if (this.finishingSessions.get(sessionId) === tracked)
|
|
1569
|
+
this.finishingSessions.delete(sessionId);
|
|
1570
|
+
});
|
|
1571
|
+
this.finishingSessions.set(sessionId, tracked);
|
|
1572
|
+
return tracked;
|
|
1573
|
+
}
|
|
1574
|
+
async finishSessionNow(sessionId) {
|
|
1575
|
+
for (const [scopeId, turn] of [...this.activeTurns]) {
|
|
1576
|
+
if (turn.sessionId !== sessionId)
|
|
1577
|
+
continue;
|
|
1578
|
+
this.clearTurnTimers(turn);
|
|
1579
|
+
await this.flushTurn(scopeId, turn, true);
|
|
1580
|
+
const finalText = [...turn.parts.values()].join('').trim();
|
|
1581
|
+
if (finalText)
|
|
1582
|
+
this.latestVoiceText.set(scopeId, finalText);
|
|
1583
|
+
if (turn.toolMessageId !== null) {
|
|
1584
|
+
if (this.config.telegramDeleteToolDetailsAfterFinal)
|
|
1585
|
+
await this.messaging.deleteMessage(scopeId, turn.toolMessageId).catch(() => { });
|
|
1586
|
+
else
|
|
1587
|
+
await this.messaging.editPlain(scopeId, turn.toolMessageId, [...turn.toolLines.values()].join('\n') || '✅ done').catch(() => { });
|
|
1588
|
+
}
|
|
1589
|
+
this.activeTurns.delete(scopeId);
|
|
1590
|
+
const next = this.queuedPrompts.get(scopeId)?.shift();
|
|
1591
|
+
if (this.queuedPrompts.get(scopeId)?.length === 0)
|
|
1592
|
+
this.queuedPrompts.delete(scopeId);
|
|
1593
|
+
if (next)
|
|
1594
|
+
await this.dispatchPrompt(next.event, next.text, next.locale);
|
|
1595
|
+
}
|
|
1596
|
+
for (const [scopeId, watch] of this.watchers) {
|
|
1597
|
+
if (watch.sessionId !== sessionId)
|
|
1598
|
+
continue;
|
|
1599
|
+
if (watch.flushTimer)
|
|
1600
|
+
clearTimeout(watch.flushTimer);
|
|
1601
|
+
watch.flushTimer = null;
|
|
1602
|
+
await this.flushWatch(scopeId, watch, true);
|
|
1603
|
+
watch.parts.clear();
|
|
1604
|
+
watch.messageIds = [];
|
|
1605
|
+
watch.renderedChunks = [];
|
|
1606
|
+
}
|
|
1607
|
+
}
|
|
1608
|
+
clearTurnTimers(turn) {
|
|
1609
|
+
if (turn.flushTimer)
|
|
1610
|
+
clearTimeout(turn.flushTimer);
|
|
1611
|
+
if (turn.toolTimer)
|
|
1612
|
+
clearTimeout(turn.toolTimer);
|
|
1613
|
+
turn.flushTimer = null;
|
|
1614
|
+
turn.toolTimer = null;
|
|
1615
|
+
}
|
|
1616
|
+
async handlePermission(request) {
|
|
1617
|
+
if (this.handlingPermissionIds.has(request.id)
|
|
1618
|
+
|| [...this.permissions.values()].some((pending) => pending.request.id === request.id))
|
|
1619
|
+
return;
|
|
1620
|
+
this.handlingPermissionIds.add(request.id);
|
|
1621
|
+
try {
|
|
1622
|
+
const scopes = this.scopesForSession(request.sessionID);
|
|
1623
|
+
const fullAccessScope = scopes.find((scopeId) => this.store.getChatSettings(scopeId)?.accessPreset === 'full-access');
|
|
1624
|
+
if (fullAccessScope) {
|
|
1625
|
+
const cwd = this.cwdForSession(fullAccessScope, request.sessionID);
|
|
1626
|
+
const response = await this.app.getClient().permission.reply({ requestID: request.id, directory: cwd, reply: 'always' });
|
|
1627
|
+
if (response.error)
|
|
1628
|
+
throw new Error(formatSdkError(response.error));
|
|
1629
|
+
this.logger.info('opencode.permission.auto_allowed', { sessionId: request.sessionID, permission: request.permission });
|
|
1630
|
+
return;
|
|
1631
|
+
}
|
|
1632
|
+
for (const scopeId of scopes) {
|
|
1633
|
+
const key = randomKey();
|
|
1634
|
+
const cwd = this.cwdForSession(scopeId, request.sessionID);
|
|
1635
|
+
const locale = this.localeForScope(scopeId);
|
|
1636
|
+
const keyboard = [[
|
|
1637
|
+
{ text: localize(locale, '✅ 本次', '✅ Once'), callback_data: `${PERMISSION_CALLBACK_PREFIX}${key}:once` },
|
|
1638
|
+
{ text: localize(locale, '♾ 总是', '♾ Always'), callback_data: `${PERMISSION_CALLBACK_PREFIX}${key}:always` },
|
|
1639
|
+
{ text: localize(locale, '🚫 拒绝', '🚫 Deny'), callback_data: `${PERMISSION_CALLBACK_PREFIX}${key}:reject` },
|
|
1640
|
+
]];
|
|
1641
|
+
const text = [
|
|
1642
|
+
localize(locale, '🛂 OpenCode 请求权限', '🛂 OpenCode permission request'),
|
|
1643
|
+
`\`${request.permission}\``,
|
|
1644
|
+
...request.patterns.slice(0, 8).map((pattern) => `• \`${pattern}\``),
|
|
1645
|
+
'',
|
|
1646
|
+
`${localize(locale, '命令回复', 'Command reply')}: /approve ${key} <once|always|reject>`,
|
|
1647
|
+
].join('\n');
|
|
1648
|
+
const messageId = await this.messaging.sendPlain(scopeId, text, keyboard);
|
|
1649
|
+
this.permissions.set(`${key}:${scopeId}`, { key, scopeId, cwd, request, messageId });
|
|
1650
|
+
}
|
|
1651
|
+
}
|
|
1652
|
+
finally {
|
|
1653
|
+
this.handlingPermissionIds.delete(request.id);
|
|
1654
|
+
}
|
|
1655
|
+
}
|
|
1656
|
+
resolvePermission(requestId) {
|
|
1657
|
+
for (const [key, pending] of this.permissions)
|
|
1658
|
+
if (pending.request.id === requestId)
|
|
1659
|
+
this.permissions.delete(key);
|
|
1660
|
+
}
|
|
1661
|
+
async approveFromCommand(scopeId, args, locale) {
|
|
1662
|
+
const pending = [...this.permissions.values()].filter((item) => item.scopeId === scopeId);
|
|
1663
|
+
if (args.length === 0) {
|
|
1664
|
+
await this.send(scopeId, pending.length
|
|
1665
|
+
? [localize(locale, '待审批:', 'Pending approvals:'), ...pending.map((item) => `\`${item.key}\` · ${item.request.permission}`), '', `/approve <id> <once|always|reject>`].join('\n')
|
|
1666
|
+
: localize(locale, '没有待审批请求。', 'No pending approvals.'));
|
|
1667
|
+
return;
|
|
1668
|
+
}
|
|
1669
|
+
const target = pending.find((item) => item.key === args[0] || item.request.id.startsWith(args[0]));
|
|
1670
|
+
if (!target)
|
|
1671
|
+
throw new Error(localize(locale, '找不到待审批请求。', 'Pending approval not found.'));
|
|
1672
|
+
const reply = args[1] === 'always' ? 'always' : args[1] === 'reject' || args[1] === 'deny' ? 'reject' : 'once';
|
|
1673
|
+
await this.replyPermission(target, reply, locale);
|
|
1674
|
+
}
|
|
1675
|
+
async replyPermission(pending, reply, locale) {
|
|
1676
|
+
const response = await this.app.getClient().permission.reply({ requestID: pending.request.id, directory: pending.cwd, reply });
|
|
1677
|
+
if (response.error)
|
|
1678
|
+
throw new Error(formatSdkError(response.error));
|
|
1679
|
+
await this.messaging.editPlain(pending.scopeId, pending.messageId, `${reply === 'reject' ? '🚫' : '✅'} ${localize(locale, reply === 'reject' ? '已拒绝' : reply === 'always' ? '已永久允许' : '已允许本次', reply === 'reject' ? 'Denied' : reply === 'always' ? 'Always allowed' : 'Allowed once')} · ${pending.request.permission}`);
|
|
1680
|
+
this.resolvePermission(pending.request.id);
|
|
1681
|
+
}
|
|
1682
|
+
async handleQuestion(request) {
|
|
1683
|
+
if (this.handlingQuestionIds.has(request.id)
|
|
1684
|
+
|| [...this.questions.values()].some((pending) => pending.request.id === request.id))
|
|
1685
|
+
return;
|
|
1686
|
+
this.handlingQuestionIds.add(request.id);
|
|
1687
|
+
try {
|
|
1688
|
+
for (const scopeId of this.scopesForSession(request.sessionID)) {
|
|
1689
|
+
const key = randomKey();
|
|
1690
|
+
const cwd = this.cwdForSession(scopeId, request.sessionID);
|
|
1691
|
+
const locale = this.localeForScope(scopeId);
|
|
1692
|
+
const pending = { key, scopeId, cwd, request, messageIds: [], answers: request.questions.map(() => []) };
|
|
1693
|
+
this.questions.set(`${key}:${scopeId}`, pending);
|
|
1694
|
+
for (let questionIndex = 0; questionIndex < request.questions.length; questionIndex++) {
|
|
1695
|
+
const question = request.questions[questionIndex];
|
|
1696
|
+
const keyboard = question.options.map((option, optionIndex) => [{
|
|
1697
|
+
text: option.label,
|
|
1698
|
+
callback_data: `${QUESTION_CALLBACK_PREFIX}${key}:${questionIndex}:${optionIndex}`,
|
|
1699
|
+
}]);
|
|
1700
|
+
if (question.multiple)
|
|
1701
|
+
keyboard.push([{ text: localize(locale, '✅ 完成多选', '✅ Done'), callback_data: `${QUESTION_CALLBACK_PREFIX}${key}:${questionIndex}:done` }]);
|
|
1702
|
+
const text = [
|
|
1703
|
+
`❓ ${question.header}`,
|
|
1704
|
+
question.question,
|
|
1705
|
+
...question.options.map((option, index) => `${index + 1}. ${option.label} — ${option.description}`),
|
|
1706
|
+
'',
|
|
1707
|
+
localize(locale, `文字回答:/answer ${key} ${questionIndex + 1} <内容>`, `Text answer: /answer ${key} ${questionIndex + 1} <text>`),
|
|
1708
|
+
].join('\n');
|
|
1709
|
+
pending.messageIds.push(await this.messaging.sendPlain(scopeId, text, keyboard));
|
|
1710
|
+
}
|
|
1711
|
+
}
|
|
1712
|
+
}
|
|
1713
|
+
finally {
|
|
1714
|
+
this.handlingQuestionIds.delete(request.id);
|
|
1715
|
+
}
|
|
1716
|
+
}
|
|
1717
|
+
resolveQuestion(requestId) {
|
|
1718
|
+
for (const [key, pending] of this.questions)
|
|
1719
|
+
if (pending.request.id === requestId)
|
|
1720
|
+
this.questions.delete(key);
|
|
1721
|
+
}
|
|
1722
|
+
async answerFromCommand(scopeId, args, locale) {
|
|
1723
|
+
const pending = [...this.questions.values()].filter((item) => item.scopeId === scopeId);
|
|
1724
|
+
if (args.length === 0) {
|
|
1725
|
+
await this.send(scopeId, pending.length
|
|
1726
|
+
? [localize(locale, '待回答:', 'Pending questions:'), ...pending.map((item) => `\`${item.key}\` · ${item.request.questions.map((q) => q.header).join(' / ')}`), '', '/answer <id> <questionNo> <text>'].join('\n')
|
|
1727
|
+
: localize(locale, '没有待回答问题。', 'No pending questions.'));
|
|
1728
|
+
return;
|
|
1729
|
+
}
|
|
1730
|
+
const target = pending.find((item) => item.key === args[0] || item.request.id.startsWith(args[0]));
|
|
1731
|
+
if (!target)
|
|
1732
|
+
throw new Error(localize(locale, '找不到待回答请求。', 'Pending question not found.'));
|
|
1733
|
+
if (args[1] === 'reject' || args[1] === 'cancel') {
|
|
1734
|
+
const response = await this.app.getClient().question.reject({ requestID: target.request.id, directory: target.cwd });
|
|
1735
|
+
if (response.error)
|
|
1736
|
+
throw new Error(formatSdkError(response.error));
|
|
1737
|
+
this.resolveQuestion(target.request.id);
|
|
1738
|
+
await this.send(scopeId, localize(locale, '已拒绝问题请求。', 'Question request rejected.'));
|
|
1739
|
+
return;
|
|
1740
|
+
}
|
|
1741
|
+
const questionIndex = Number.parseInt(args[1] ?? '', 10) - 1;
|
|
1742
|
+
const answer = args.slice(2).join(' ').trim();
|
|
1743
|
+
if (!target.request.questions[questionIndex] || !answer)
|
|
1744
|
+
throw new Error('/answer <id> <questionNo> <text>');
|
|
1745
|
+
target.answers[questionIndex] = [answer];
|
|
1746
|
+
await this.maybeSubmitQuestion(target, locale);
|
|
1747
|
+
}
|
|
1748
|
+
async maybeSubmitQuestion(pending, locale) {
|
|
1749
|
+
if (pending.answers.some((answer) => answer.length === 0))
|
|
1750
|
+
return;
|
|
1751
|
+
const response = await this.app.getClient().question.reply({
|
|
1752
|
+
requestID: pending.request.id,
|
|
1753
|
+
directory: pending.cwd,
|
|
1754
|
+
answers: pending.answers,
|
|
1755
|
+
});
|
|
1756
|
+
if (response.error)
|
|
1757
|
+
throw new Error(formatSdkError(response.error));
|
|
1758
|
+
for (let index = 0; index < pending.messageIds.length; index++) {
|
|
1759
|
+
await this.messaging.editPlain(pending.scopeId, pending.messageIds[index], `✅ ${pending.request.questions[index]?.header ?? localize(locale, '已回答', 'Answered')}: ${pending.answers[index].join(', ')}`).catch(() => { });
|
|
1760
|
+
}
|
|
1761
|
+
this.resolveQuestion(pending.request.id);
|
|
1762
|
+
}
|
|
1763
|
+
async handleCallback(event) {
|
|
1764
|
+
const locale = this.localeForScope(event.scopeId, event.languageCode);
|
|
1765
|
+
if (event.data.startsWith(SETUP_CALLBACK_PREFIX)) {
|
|
1766
|
+
const key = event.data.slice(SETUP_CALLBACK_PREFIX.length);
|
|
1767
|
+
const action = this.setupActions.get(key);
|
|
1768
|
+
if (!action || action.scopeId !== event.scopeId) {
|
|
1769
|
+
await this.messaging.answerCallback(event.callbackQueryId, localize(locale, '已过期', 'Expired'));
|
|
1770
|
+
return;
|
|
1771
|
+
}
|
|
1772
|
+
this.setupActions.delete(key);
|
|
1773
|
+
if (action.kind === 'setup') {
|
|
1774
|
+
await this.messaging.answerCallback(event.callbackQueryId, localize(locale, '设置', 'Settings'));
|
|
1775
|
+
await this.showSetup(event.scopeId, locale, event.messageId);
|
|
1776
|
+
}
|
|
1777
|
+
else if (action.kind === 'models') {
|
|
1778
|
+
await this.messaging.answerCallback(event.callbackQueryId, localize(locale, 'Provider', 'Providers'));
|
|
1779
|
+
await this.showModels(event.scopeId, '', locale, event.messageId, action.origin ?? 'models');
|
|
1780
|
+
}
|
|
1781
|
+
else if (action.kind === 'provider') {
|
|
1782
|
+
await this.messaging.answerCallback(event.callbackQueryId, action.value);
|
|
1783
|
+
await this.showModels(event.scopeId, action.value, locale, event.messageId, action.origin ?? 'models');
|
|
1784
|
+
}
|
|
1785
|
+
else if (action.kind === 'model') {
|
|
1786
|
+
let answer;
|
|
1787
|
+
if (action.value === 'default') {
|
|
1788
|
+
const settings = this.store.getChatSettings(event.scopeId);
|
|
1789
|
+
this.store.setChatSettings(event.scopeId, null, settings?.reasoningEffort ?? null);
|
|
1790
|
+
this.writePrefs(event.scopeId, { ...readPrefs(settings), variant: null });
|
|
1791
|
+
answer = localize(locale, '服务端默认', 'Server default');
|
|
1792
|
+
}
|
|
1793
|
+
else {
|
|
1794
|
+
const parsed = parseStoredModel(action.value);
|
|
1795
|
+
this.store.setChatSettings(event.scopeId, action.value, null);
|
|
1796
|
+
this.writePrefs(event.scopeId, { ...readPrefs(this.store.getChatSettings(event.scopeId)), variant: null });
|
|
1797
|
+
answer = `${parsed.providerId}/${parsed.modelId}`;
|
|
1798
|
+
}
|
|
1799
|
+
await this.messaging.answerCallback(event.callbackQueryId, answer);
|
|
1800
|
+
if (action.origin === 'setup')
|
|
1801
|
+
await this.showSetup(event.scopeId, locale, event.messageId);
|
|
1802
|
+
else {
|
|
1803
|
+
const parsed = action.value === 'default' ? null : parseStoredModel(action.value);
|
|
1804
|
+
await this.showModels(event.scopeId, parsed?.providerId ?? '', locale, event.messageId, 'models');
|
|
1805
|
+
}
|
|
1806
|
+
}
|
|
1807
|
+
else if (action.kind === 'variant') {
|
|
1808
|
+
const prefs = readPrefs(this.store.getChatSettings(event.scopeId));
|
|
1809
|
+
this.writePrefs(event.scopeId, { ...prefs, variant: action.value === 'default' ? null : action.value });
|
|
1810
|
+
await this.messaging.answerCallback(event.callbackQueryId, action.value);
|
|
1811
|
+
await this.showSetup(event.scopeId, locale, event.messageId);
|
|
1812
|
+
}
|
|
1813
|
+
else if (action.kind === 'access') {
|
|
1814
|
+
if (action.value !== 'read-only' && action.value !== 'default' && action.value !== 'full-access') {
|
|
1815
|
+
throw new Error(`Invalid access preset: ${action.value}`);
|
|
1816
|
+
}
|
|
1817
|
+
await this.applyAccess(event.scopeId, action.value);
|
|
1818
|
+
await this.messaging.answerCallback(event.callbackQueryId, action.value);
|
|
1819
|
+
await this.showSetup(event.scopeId, locale, event.messageId);
|
|
1820
|
+
}
|
|
1821
|
+
else if (action.kind === 'mode') {
|
|
1822
|
+
this.store.setChatCollaborationMode(event.scopeId, action.value === 'plan' ? 'plan' : 'default');
|
|
1823
|
+
await this.messaging.answerCallback(event.callbackQueryId, action.value);
|
|
1824
|
+
await this.showSetup(event.scopeId, locale, event.messageId);
|
|
1825
|
+
}
|
|
1826
|
+
else if (action.kind === 'agent') {
|
|
1827
|
+
const prefs = readPrefs(this.store.getChatSettings(event.scopeId));
|
|
1828
|
+
this.writePrefs(event.scopeId, { ...prefs, agent: action.value === 'build' ? 'build' : action.value });
|
|
1829
|
+
this.store.setChatCollaborationMode(event.scopeId, 'default');
|
|
1830
|
+
await this.messaging.answerCallback(event.callbackQueryId, action.value);
|
|
1831
|
+
await this.showSetup(event.scopeId, locale, event.messageId);
|
|
1832
|
+
}
|
|
1833
|
+
else if (action.kind === 'active') {
|
|
1834
|
+
this.store.setChatActiveTurnMessageMode(event.scopeId, action.value === 'queue' ? 'queue' : 'steer');
|
|
1835
|
+
await this.messaging.answerCallback(event.callbackQueryId, action.value);
|
|
1836
|
+
await this.showSetup(event.scopeId, locale, event.messageId);
|
|
1837
|
+
}
|
|
1838
|
+
else if (action.kind === 'notice') {
|
|
1839
|
+
await this.messaging.answerCallback(event.callbackQueryId, localize(locale, 'OpenCode 没有 Codex Fast 服务层等价项', 'OpenCode has no Codex Fast service-tier equivalent'));
|
|
1840
|
+
}
|
|
1841
|
+
else if (action.kind === 'open') {
|
|
1842
|
+
await this.openSession(event.scopeId, action.value, locale);
|
|
1843
|
+
await this.messaging.answerCallback(event.callbackQueryId, localize(locale, '已打开', 'Opened'));
|
|
1844
|
+
}
|
|
1845
|
+
else {
|
|
1846
|
+
await this.watchSession(event.scopeId, action.value, locale);
|
|
1847
|
+
await this.messaging.answerCallback(event.callbackQueryId, localize(locale, '正在观察', 'Watching'));
|
|
1848
|
+
}
|
|
1849
|
+
return;
|
|
1850
|
+
}
|
|
1851
|
+
if (event.data.startsWith(PERMISSION_CALLBACK_PREFIX)) {
|
|
1852
|
+
const [key, rawReply] = event.data.slice(PERMISSION_CALLBACK_PREFIX.length).split(':');
|
|
1853
|
+
const pending = this.permissions.get(`${key}:${event.scopeId}`);
|
|
1854
|
+
if (!pending) {
|
|
1855
|
+
await this.messaging.answerCallback(event.callbackQueryId, localize(locale, '已过期', 'Expired'));
|
|
1856
|
+
return;
|
|
1857
|
+
}
|
|
1858
|
+
const reply = rawReply === 'always' ? 'always' : rawReply === 'reject' ? 'reject' : 'once';
|
|
1859
|
+
await this.replyPermission(pending, reply, locale);
|
|
1860
|
+
await this.messaging.answerCallback(event.callbackQueryId, reply);
|
|
1861
|
+
return;
|
|
1862
|
+
}
|
|
1863
|
+
if (event.data.startsWith(QUESTION_CALLBACK_PREFIX)) {
|
|
1864
|
+
const [key, questionRaw, optionRaw] = event.data.slice(QUESTION_CALLBACK_PREFIX.length).split(':');
|
|
1865
|
+
const pending = this.questions.get(`${key}:${event.scopeId}`);
|
|
1866
|
+
const questionIndex = Number.parseInt(questionRaw ?? '', 10);
|
|
1867
|
+
const question = pending?.request.questions[questionIndex];
|
|
1868
|
+
if (!pending || !question) {
|
|
1869
|
+
await this.messaging.answerCallback(event.callbackQueryId, localize(locale, '已过期', 'Expired'));
|
|
1870
|
+
return;
|
|
1871
|
+
}
|
|
1872
|
+
if (optionRaw === 'done') {
|
|
1873
|
+
if (pending.answers[questionIndex].length === 0) {
|
|
1874
|
+
await this.messaging.answerCallback(event.callbackQueryId, localize(locale, '请至少选择一项', 'Select at least one'));
|
|
1875
|
+
return;
|
|
1876
|
+
}
|
|
1877
|
+
}
|
|
1878
|
+
else {
|
|
1879
|
+
const optionIndex = Number.parseInt(optionRaw ?? '', 10);
|
|
1880
|
+
const label = question.options[optionIndex]?.label;
|
|
1881
|
+
if (!label) {
|
|
1882
|
+
await this.messaging.answerCallback(event.callbackQueryId, localize(locale, '无效选项', 'Invalid option'));
|
|
1883
|
+
return;
|
|
1884
|
+
}
|
|
1885
|
+
if (question.multiple) {
|
|
1886
|
+
const answers = pending.answers[questionIndex];
|
|
1887
|
+
const existing = answers.indexOf(label);
|
|
1888
|
+
if (existing >= 0)
|
|
1889
|
+
answers.splice(existing, 1);
|
|
1890
|
+
else
|
|
1891
|
+
answers.push(label);
|
|
1892
|
+
await this.messaging.answerCallback(event.callbackQueryId, answers.join(', ') || localize(locale, '已清空', 'Cleared'));
|
|
1893
|
+
return;
|
|
1894
|
+
}
|
|
1895
|
+
pending.answers[questionIndex] = [label];
|
|
1896
|
+
}
|
|
1897
|
+
await this.messaging.answerCallback(event.callbackQueryId, localize(locale, '已记录', 'Recorded'));
|
|
1898
|
+
await this.maybeSubmitQuestion(pending, locale);
|
|
1899
|
+
}
|
|
1900
|
+
}
|
|
1901
|
+
async send(scopeId, text) {
|
|
1902
|
+
const chunks = chunkTelegramMessage(text);
|
|
1903
|
+
let first = 0;
|
|
1904
|
+
for (const chunk of chunks) {
|
|
1905
|
+
const id = await this.messaging.sendPlain(scopeId, chunk);
|
|
1906
|
+
if (!first)
|
|
1907
|
+
first = id;
|
|
1908
|
+
}
|
|
1909
|
+
return first;
|
|
1910
|
+
}
|
|
1911
|
+
async sendFinalChunk(scopeId, markdown) {
|
|
1912
|
+
try {
|
|
1913
|
+
return await this.messaging.sendRichMarkdown(scopeId, markdown);
|
|
1914
|
+
}
|
|
1915
|
+
catch (error) {
|
|
1916
|
+
this.logger.warn('opencode.rich_send_failed', { error: error instanceof Error ? error.message : String(error) });
|
|
1917
|
+
return this.messaging.sendPlain(scopeId, markdown);
|
|
1918
|
+
}
|
|
1919
|
+
}
|
|
1920
|
+
async editFinalChunk(scopeId, messageId, markdown) {
|
|
1921
|
+
try {
|
|
1922
|
+
await this.messaging.editRichMarkdown(scopeId, messageId, markdown);
|
|
1923
|
+
}
|
|
1924
|
+
catch (error) {
|
|
1925
|
+
this.logger.warn('opencode.rich_edit_failed', { error: error instanceof Error ? error.message : String(error) });
|
|
1926
|
+
await this.messaging.editPlain(scopeId, messageId, markdown);
|
|
1927
|
+
}
|
|
1928
|
+
}
|
|
1929
|
+
}
|
|
1930
|
+
function unwrap(response, operation) {
|
|
1931
|
+
if (response.error !== undefined)
|
|
1932
|
+
throw new Error(`${operation}: ${formatSdkError(response.error)}`);
|
|
1933
|
+
if (response.data === undefined || response.data === null)
|
|
1934
|
+
throw new Error(`${operation}: OpenCode returned no data`);
|
|
1935
|
+
return response.data;
|
|
1936
|
+
}
|
|
1937
|
+
function localize(locale, zh, en) {
|
|
1938
|
+
return locale === 'zh' ? zh : en;
|
|
1939
|
+
}
|
|
1940
|
+
function shortId(value) {
|
|
1941
|
+
return value.length > 18 ? `${value.slice(0, 17)}…` : value;
|
|
1942
|
+
}
|
|
1943
|
+
function clip(value, max) {
|
|
1944
|
+
const normalized = value.replace(/\s+/g, ' ').trim();
|
|
1945
|
+
return normalized.length > max ? `${normalized.slice(0, max - 1)}…` : normalized;
|
|
1946
|
+
}
|
|
1947
|
+
function selectedLabel(selected, label) {
|
|
1948
|
+
return selected ? `• ${label}` : label;
|
|
1949
|
+
}
|
|
1950
|
+
function formatStoredModel(value, locale) {
|
|
1951
|
+
if (!value)
|
|
1952
|
+
return localize(locale, '服务端默认', 'server default');
|
|
1953
|
+
const parsed = parseStoredModel(value);
|
|
1954
|
+
return parsed ? `${parsed.providerId}/${parsed.modelId}` : value;
|
|
1955
|
+
}
|
|
1956
|
+
function randomKey() {
|
|
1957
|
+
return Math.random().toString(36).slice(2, 9);
|
|
1958
|
+
}
|
|
1959
|
+
function clampInt(raw, fallback, min, max) {
|
|
1960
|
+
const parsed = Number.parseInt(raw, 10);
|
|
1961
|
+
return Number.isFinite(parsed) ? Math.max(min, Math.min(max, parsed)) : fallback;
|
|
1962
|
+
}
|
|
1963
|
+
function formatAge(timestamp, locale) {
|
|
1964
|
+
const seconds = Math.max(0, Math.round((Date.now() - timestamp) / 1000));
|
|
1965
|
+
if (seconds < 60)
|
|
1966
|
+
return localize(locale, `${seconds} 秒前`, `${seconds}s ago`);
|
|
1967
|
+
const minutes = Math.round(seconds / 60);
|
|
1968
|
+
if (minutes < 60)
|
|
1969
|
+
return localize(locale, `${minutes} 分钟前`, `${minutes}m ago`);
|
|
1970
|
+
const hours = Math.round(minutes / 60);
|
|
1971
|
+
if (hours < 48)
|
|
1972
|
+
return localize(locale, `${hours} 小时前`, `${hours}h ago`);
|
|
1973
|
+
const days = Math.round(hours / 24);
|
|
1974
|
+
return localize(locale, `${days} 天前`, `${days}d ago`);
|
|
1975
|
+
}
|
|
1976
|
+
function storeModel(providerId, modelId) {
|
|
1977
|
+
return `${providerId}::${modelId}`;
|
|
1978
|
+
}
|
|
1979
|
+
function parseStoredModel(value) {
|
|
1980
|
+
const separator = value.indexOf('::');
|
|
1981
|
+
if (separator > 0)
|
|
1982
|
+
return { providerId: value.slice(0, separator), modelId: value.slice(separator + 2) };
|
|
1983
|
+
const slash = value.indexOf('/');
|
|
1984
|
+
return slash > 0 ? { providerId: value.slice(0, slash), modelId: value.slice(slash + 1) } : null;
|
|
1985
|
+
}
|
|
1986
|
+
function readPrefs(settings) {
|
|
1987
|
+
const raw = settings?.serviceTier;
|
|
1988
|
+
if (!raw?.startsWith('opencode:'))
|
|
1989
|
+
return { agent: null, variant: null };
|
|
1990
|
+
try {
|
|
1991
|
+
const value = JSON.parse(raw.slice('opencode:'.length));
|
|
1992
|
+
return {
|
|
1993
|
+
agent: typeof value.agent === 'string' ? value.agent : null,
|
|
1994
|
+
variant: typeof value.variant === 'string' ? value.variant : null,
|
|
1995
|
+
};
|
|
1996
|
+
}
|
|
1997
|
+
catch {
|
|
1998
|
+
return { agent: null, variant: null };
|
|
1999
|
+
}
|
|
2000
|
+
}
|
|
2001
|
+
export function permissionRules(access) {
|
|
2002
|
+
if (access === 'read-only')
|
|
2003
|
+
return [
|
|
2004
|
+
{ permission: 'edit', pattern: '*', action: 'deny' },
|
|
2005
|
+
{ permission: 'bash', pattern: '*', action: 'deny' },
|
|
2006
|
+
{ permission: 'external_directory', pattern: '*', action: 'deny' },
|
|
2007
|
+
];
|
|
2008
|
+
return [];
|
|
2009
|
+
}
|