@bd7pil/ocrc 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +34 -0
- package/README.md +330 -0
- package/dist/cli/host-backends.js +216 -0
- package/dist/cli/host-backends.js.map +1 -0
- package/dist/cli/host.js +185 -0
- package/dist/cli/host.js.map +1 -0
- package/dist/cli/index.js +67 -0
- package/dist/cli/index.js.map +1 -0
- package/dist/cli/init.js +68 -0
- package/dist/cli/init.js.map +1 -0
- package/dist/cli/install.js +220 -0
- package/dist/cli/install.js.map +1 -0
- package/dist/cli/pair.js +11 -0
- package/dist/cli/pair.js.map +1 -0
- package/dist/cli/uninstall.js +75 -0
- package/dist/cli/uninstall.js.map +1 -0
- package/dist/connectivity/auth/cf-access.js +20 -0
- package/dist/connectivity/auth/cf-access.js.map +1 -0
- package/dist/connectivity/auth/index.js +2 -0
- package/dist/connectivity/auth/index.js.map +1 -0
- package/dist/connectivity/auth/select.js +19 -0
- package/dist/connectivity/auth/select.js.map +1 -0
- package/dist/connectivity/auth/token.js +157 -0
- package/dist/connectivity/auth/token.js.map +1 -0
- package/dist/connectivity/exposure/cloudflared.js +77 -0
- package/dist/connectivity/exposure/cloudflared.js.map +1 -0
- package/dist/connectivity/exposure/index.js +2 -0
- package/dist/connectivity/exposure/index.js.map +1 -0
- package/dist/connectivity/exposure/providers.js +49 -0
- package/dist/connectivity/exposure/providers.js.map +1 -0
- package/dist/connectivity/pairing.js +29 -0
- package/dist/connectivity/pairing.js.map +1 -0
- package/dist/core/agent/acp-backend.js +539 -0
- package/dist/core/agent/acp-backend.js.map +1 -0
- package/dist/core/agent/acp-connect.js +92 -0
- package/dist/core/agent/acp-connect.js.map +1 -0
- package/dist/core/agent/acp-normalizer.js +81 -0
- package/dist/core/agent/acp-normalizer.js.map +1 -0
- package/dist/core/agent/acp-replay.js +89 -0
- package/dist/core/agent/acp-replay.js.map +1 -0
- package/dist/core/agent/acp-store.js +129 -0
- package/dist/core/agent/acp-store.js.map +1 -0
- package/dist/core/agent/backend.js +2 -0
- package/dist/core/agent/backend.js.map +1 -0
- package/dist/core/agent/diff-util.js +70 -0
- package/dist/core/agent/diff-util.js.map +1 -0
- package/dist/core/agent/event.js +12 -0
- package/dist/core/agent/event.js.map +1 -0
- package/dist/core/agent/opencode-backend.js +598 -0
- package/dist/core/agent/opencode-backend.js.map +1 -0
- package/dist/core/agent/opencode-normalizer.js +69 -0
- package/dist/core/agent/opencode-normalizer.js.map +1 -0
- package/dist/core/agent/registry.js +55 -0
- package/dist/core/agent/registry.js.map +1 -0
- package/dist/core/agent/v2-backend.js +176 -0
- package/dist/core/agent/v2-backend.js.map +1 -0
- package/dist/core/card-bus.js +74 -0
- package/dist/core/card-bus.js.map +1 -0
- package/dist/core/channels.js +95 -0
- package/dist/core/channels.js.map +1 -0
- package/dist/core/history.js +91 -0
- package/dist/core/history.js.map +1 -0
- package/dist/core/list-files.js +50 -0
- package/dist/core/list-files.js.map +1 -0
- package/dist/core/opencode-events.js +29 -0
- package/dist/core/opencode-events.js.map +1 -0
- package/dist/core/primary-election.js +91 -0
- package/dist/core/primary-election.js.map +1 -0
- package/dist/core/push.js +152 -0
- package/dist/core/push.js.map +1 -0
- package/dist/core/relay.js +400 -0
- package/dist/core/relay.js.map +1 -0
- package/dist/core/scheduler.js +150 -0
- package/dist/core/scheduler.js.map +1 -0
- package/dist/core/state.js +222 -0
- package/dist/core/state.js.map +1 -0
- package/dist/core/stream-accumulator.js +76 -0
- package/dist/core/stream-accumulator.js.map +1 -0
- package/dist/core/structured-card.js +2 -0
- package/dist/core/structured-card.js.map +1 -0
- package/dist/core/types.js +2 -0
- package/dist/core/types.js.map +1 -0
- package/dist/index.js +3 -0
- package/dist/index.js.map +1 -0
- package/dist/opencode/global-events.js +57 -0
- package/dist/opencode/global-events.js.map +1 -0
- package/dist/opencode/list-sessions.js +122 -0
- package/dist/opencode/list-sessions.js.map +1 -0
- package/dist/opencode/submit.js +39 -0
- package/dist/opencode/submit.js.map +1 -0
- package/dist/opencode/workspaces.js +51 -0
- package/dist/opencode/workspaces.js.map +1 -0
- package/dist/plugin/config.js +128 -0
- package/dist/plugin/config.js.map +1 -0
- package/dist/plugin/control-plane.js +133 -0
- package/dist/plugin/control-plane.js.map +1 -0
- package/dist/plugin/entry.js +409 -0
- package/dist/plugin/entry.js.map +1 -0
- package/dist/plugin/v2/event-map.js +193 -0
- package/dist/plugin/v2/event-map.js.map +1 -0
- package/dist/plugin/v2/types.js +15 -0
- package/dist/plugin/v2/types.js.map +1 -0
- package/dist/transport/interface.js +2 -0
- package/dist/transport/interface.js.map +1 -0
- package/dist/transport/telegram/esc.js +9 -0
- package/dist/transport/telegram/esc.js.map +1 -0
- package/dist/transport/telegram/handlers/info-commands.js +101 -0
- package/dist/transport/telegram/handlers/info-commands.js.map +1 -0
- package/dist/transport/telegram/handlers.js +1348 -0
- package/dist/transport/telegram/handlers.js.map +1 -0
- package/dist/transport/telegram/i18n/en.js +538 -0
- package/dist/transport/telegram/i18n/en.js.map +1 -0
- package/dist/transport/telegram/i18n/index.js +83 -0
- package/dist/transport/telegram/i18n/index.js.map +1 -0
- package/dist/transport/telegram/i18n/zh.js +538 -0
- package/dist/transport/telegram/i18n/zh.js.map +1 -0
- package/dist/transport/telegram/index.js +792 -0
- package/dist/transport/telegram/index.js.map +1 -0
- package/dist/transport/telegram/main-keyboard.js +36 -0
- package/dist/transport/telegram/main-keyboard.js.map +1 -0
- package/dist/transport/telegram/managers/abort-suppression-manager.js +33 -0
- package/dist/transport/telegram/managers/abort-suppression-manager.js.map +1 -0
- package/dist/transport/telegram/managers/external-input-suppression-manager.js +51 -0
- package/dist/transport/telegram/managers/external-input-suppression-manager.js.map +1 -0
- package/dist/transport/telegram/managers/interaction-manager.js +254 -0
- package/dist/transport/telegram/managers/interaction-manager.js.map +1 -0
- package/dist/transport/telegram/managers/permission-manager.js +242 -0
- package/dist/transport/telegram/managers/permission-manager.js.map +1 -0
- package/dist/transport/telegram/managers/question-manager.js +191 -0
- package/dist/transport/telegram/managers/question-manager.js.map +1 -0
- package/dist/transport/telegram/managers/rename-manager.js +56 -0
- package/dist/transport/telegram/managers/rename-manager.js.map +1 -0
- package/dist/transport/telegram/menus.js +77 -0
- package/dist/transport/telegram/menus.js.map +1 -0
- package/dist/transport/telegram/permission-flow.js +156 -0
- package/dist/transport/telegram/permission-flow.js.map +1 -0
- package/dist/transport/telegram/question-flow.js +224 -0
- package/dist/transport/telegram/question-flow.js.map +1 -0
- package/dist/transport/telegram/render/block-parser.js +365 -0
- package/dist/transport/telegram/render/block-parser.js.map +1 -0
- package/dist/transport/telegram/render/block-plain-text.js +87 -0
- package/dist/transport/telegram/render/block-plain-text.js.map +1 -0
- package/dist/transport/telegram/render/block-splitter.js +152 -0
- package/dist/transport/telegram/render/block-splitter.js.map +1 -0
- package/dist/transport/telegram/render/chunker.js +71 -0
- package/dist/transport/telegram/render/chunker.js.map +1 -0
- package/dist/transport/telegram/render/limits.js +13 -0
- package/dist/transport/telegram/render/limits.js.map +1 -0
- package/dist/transport/telegram/render/markdown-normalizer.js +102 -0
- package/dist/transport/telegram/render/markdown-normalizer.js.map +1 -0
- package/dist/transport/telegram/render/markdown-to-telegram-v2.js +116 -0
- package/dist/transport/telegram/render/markdown-to-telegram-v2.js.map +1 -0
- package/dist/transport/telegram/render/part-signature.js +14 -0
- package/dist/transport/telegram/render/part-signature.js.map +1 -0
- package/dist/transport/telegram/render/pipeline.js +23 -0
- package/dist/transport/telegram/render/pipeline.js.map +1 -0
- package/dist/transport/telegram/render/rich-blocks.js +311 -0
- package/dist/transport/telegram/render/rich-blocks.js.map +1 -0
- package/dist/transport/telegram/render/text-splitter.js +64 -0
- package/dist/transport/telegram/render/text-splitter.js.map +1 -0
- package/dist/transport/telegram/render/types.js +2 -0
- package/dist/transport/telegram/render/types.js.map +1 -0
- package/dist/transport/telegram/renderer.js +305 -0
- package/dist/transport/telegram/renderer.js.map +1 -0
- package/dist/transport/telegram/streaming/duration-formatter.js +68 -0
- package/dist/transport/telegram/streaming/duration-formatter.js.map +1 -0
- package/dist/transport/telegram/streaming/finalize-assistant-response.js +37 -0
- package/dist/transport/telegram/streaming/finalize-assistant-response.js.map +1 -0
- package/dist/transport/telegram/streaming/response-streamer.js +400 -0
- package/dist/transport/telegram/streaming/response-streamer.js.map +1 -0
- package/dist/transport/telegram/streaming/running-tool-tracker.js +150 -0
- package/dist/transport/telegram/streaming/running-tool-tracker.js.map +1 -0
- package/dist/transport/telegram/streaming/stream-throttle.js +57 -0
- package/dist/transport/telegram/streaming/stream-throttle.js.map +1 -0
- package/dist/transport/telegram/streaming/tool-call-streamer.js +390 -0
- package/dist/transport/telegram/streaming/tool-call-streamer.js.map +1 -0
- package/dist/transport/telegram/streaming-render.js +182 -0
- package/dist/transport/telegram/streaming-render.js.map +1 -0
- package/dist/transport/telegram/types/interaction.js +2 -0
- package/dist/transport/telegram/types/interaction.js.map +1 -0
- package/dist/transport/telegram/types/model.js +25 -0
- package/dist/transport/telegram/types/model.js.map +1 -0
- package/dist/transport/telegram/types/permission.js +2 -0
- package/dist/transport/telegram/types/permission.js.map +1 -0
- package/dist/transport/telegram/types/question.js +2 -0
- package/dist/transport/telegram/types/question.js.map +1 -0
- package/dist/transport/telegram/types/rename.js +2 -0
- package/dist/transport/telegram/types/rename.js.map +1 -0
- package/dist/transport/telegram/types/scheduled-task.js +23 -0
- package/dist/transport/telegram/types/scheduled-task.js.map +1 -0
- package/dist/transport/telegram/types/session.js +2 -0
- package/dist/transport/telegram/types/session.js.map +1 -0
- package/dist/transport/telegram/ui.js +28 -0
- package/dist/transport/telegram/ui.js.map +1 -0
- package/dist/transport/web/index.js +148 -0
- package/dist/transport/web/index.js.map +1 -0
- package/dist/transport/web/middleware/cf-access.js +104 -0
- package/dist/transport/web/middleware/cf-access.js.map +1 -0
- package/dist/transport/web/opencode-config.js +15 -0
- package/dist/transport/web/opencode-config.js.map +1 -0
- package/dist/transport/web/routes/abort.js +12 -0
- package/dist/transport/web/routes/abort.js.map +1 -0
- package/dist/transport/web/routes/approval.js +16 -0
- package/dist/transport/web/routes/approval.js.map +1 -0
- package/dist/transport/web/routes/catalog.js +11 -0
- package/dist/transport/web/routes/catalog.js.map +1 -0
- package/dist/transport/web/routes/channels.js +53 -0
- package/dist/transport/web/routes/channels.js.map +1 -0
- package/dist/transport/web/routes/commands.js +15 -0
- package/dist/transport/web/routes/commands.js.map +1 -0
- package/dist/transport/web/routes/context.js +17 -0
- package/dist/transport/web/routes/context.js.map +1 -0
- package/dist/transport/web/routes/controls.js +34 -0
- package/dist/transport/web/routes/controls.js.map +1 -0
- package/dist/transport/web/routes/create-session.js +21 -0
- package/dist/transport/web/routes/create-session.js.map +1 -0
- package/dist/transport/web/routes/diff.js +7 -0
- package/dist/transport/web/routes/diff.js.map +1 -0
- package/dist/transport/web/routes/files.js +22 -0
- package/dist/transport/web/routes/files.js.map +1 -0
- package/dist/transport/web/routes/logs.js +9 -0
- package/dist/transport/web/routes/logs.js.map +1 -0
- package/dist/transport/web/routes/m8.js +69 -0
- package/dist/transport/web/routes/m8.js.map +1 -0
- package/dist/transport/web/routes/mcp.js +7 -0
- package/dist/transport/web/routes/mcp.js.map +1 -0
- package/dist/transport/web/routes/message.js +32 -0
- package/dist/transport/web/routes/message.js.map +1 -0
- package/dist/transport/web/routes/overrides.js +20 -0
- package/dist/transport/web/routes/overrides.js.map +1 -0
- package/dist/transport/web/routes/question.js +32 -0
- package/dist/transport/web/routes/question.js.map +1 -0
- package/dist/transport/web/routes/rename.js +12 -0
- package/dist/transport/web/routes/rename.js.map +1 -0
- package/dist/transport/web/routes/schedules.js +28 -0
- package/dist/transport/web/routes/schedules.js.map +1 -0
- package/dist/transport/web/routes/session.js +20 -0
- package/dist/transport/web/routes/session.js.map +1 -0
- package/dist/transport/web/routes/sessions.js +25 -0
- package/dist/transport/web/routes/sessions.js.map +1 -0
- package/dist/transport/web/routes/subagents.js +11 -0
- package/dist/transport/web/routes/subagents.js.map +1 -0
- package/dist/transport/web/routes/suggestions.js +7 -0
- package/dist/transport/web/routes/suggestions.js.map +1 -0
- package/dist/transport/web/routes/todo.js +7 -0
- package/dist/transport/web/routes/todo.js.map +1 -0
- package/dist/transport/web/routes/version.js +5 -0
- package/dist/transport/web/routes/version.js.map +1 -0
- package/dist/transport/web/routes/workspaces.js +7 -0
- package/dist/transport/web/routes/workspaces.js.map +1 -0
- package/dist/transport/web/server.js +111 -0
- package/dist/transport/web/server.js.map +1 -0
- package/dist/transport/web/session-summary.js +46 -0
- package/dist/transport/web/session-summary.js.map +1 -0
- package/dist/transport/web/ws-hub.js +83 -0
- package/dist/transport/web/ws-hub.js.map +1 -0
- package/dist/utils/logger.js +105 -0
- package/dist/utils/logger.js.map +1 -0
- package/dist/utils/markdown.js +129 -0
- package/dist/utils/markdown.js.map +1 -0
- package/dist/utils/paths.js +18 -0
- package/dist/utils/paths.js.map +1 -0
- package/dist/utils/version.js +44 -0
- package/dist/utils/version.js.map +1 -0
- package/package.json +91 -0
- package/web/dist/_app/env.js +1 -0
- package/web/dist/_app/immutable/assets/0.DxN3RsuG.css +1 -0
- package/web/dist/_app/immutable/assets/2.BNuGfmZP.css +1 -0
- package/web/dist/_app/immutable/assets/3.CTDprntu.css +1 -0
- package/web/dist/_app/immutable/chunks/BUpwHpMJ.js +1 -0
- package/web/dist/_app/immutable/chunks/BjY4dxcB.js +2 -0
- package/web/dist/_app/immutable/chunks/C2FuqD3s.js +1 -0
- package/web/dist/_app/immutable/chunks/CUHGq08X.js +2 -0
- package/web/dist/_app/immutable/chunks/CWj6FrbW.js +1 -0
- package/web/dist/_app/immutable/chunks/ClvoqtGj.js +1 -0
- package/web/dist/_app/immutable/chunks/DIEDQ_hk.js +1 -0
- package/web/dist/_app/immutable/entry/app.BoRpcVMj.js +2 -0
- package/web/dist/_app/immutable/entry/start.Cj-PQNTq.js +1 -0
- package/web/dist/_app/immutable/nodes/0.y9YEms5p.js +3 -0
- package/web/dist/_app/immutable/nodes/1.CPhhiRM2.js +1 -0
- package/web/dist/_app/immutable/nodes/2.B_c84Mc9.js +1 -0
- package/web/dist/_app/immutable/nodes/3.ClTd0J3D.js +64 -0
- package/web/dist/_app/version.json +1 -0
- package/web/dist/apple-touch-icon.png +0 -0
- package/web/dist/favicon.png +0 -0
- package/web/dist/favicon.svg +17 -0
- package/web/dist/icon-192.png +0 -0
- package/web/dist/icon-512-maskable.png +0 -0
- package/web/dist/icon-512.png +0 -0
- package/web/dist/index.html +73 -0
- package/web/dist/manifest.webmanifest +28 -0
- package/web/dist/service-worker.js +1 -0
|
@@ -0,0 +1,792 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { Bot, InlineKeyboard } from 'grammy';
|
|
3
|
+
import { errorCodeOf, inlineKeyboard, btn } from './ui.js';
|
|
4
|
+
import { isEphemeralSession } from '../../opencode/submit.js';
|
|
5
|
+
import { TelegramSessionRenderer } from './renderer.js';
|
|
6
|
+
import { StreamingRenderer } from './streaming-render.js';
|
|
7
|
+
import { PermissionFlow } from './permission-flow.js';
|
|
8
|
+
import { QuestionFlow } from './question-flow.js';
|
|
9
|
+
import { renderSessionsMenu, renderAgentsMenu, renderModelsMenu, editMenu } from './menus.js';
|
|
10
|
+
import { InteractionManager } from './managers/interaction-manager.js';
|
|
11
|
+
import { PermissionManager } from './managers/permission-manager.js';
|
|
12
|
+
import { buildMainKeyboard, AGENT_BUTTON_TEXT_PATTERN, MODEL_BUTTON_TEXT_PATTERN, CONTEXT_BUTTON_TEXT_PATTERN, } from './main-keyboard.js';
|
|
13
|
+
import { registerHandlers } from './handlers.js';
|
|
14
|
+
import { esc } from './esc.js';
|
|
15
|
+
import { createLogger } from '../../utils/logger.js';
|
|
16
|
+
const log = createLogger('telegram');
|
|
17
|
+
const CAPS = {
|
|
18
|
+
edit: true,
|
|
19
|
+
maxMessageLength: 4000,
|
|
20
|
+
buttons: true,
|
|
21
|
+
richText: true,
|
|
22
|
+
streaming: false,
|
|
23
|
+
};
|
|
24
|
+
export function createTelegramTransport(cfg, injected) {
|
|
25
|
+
// DI seam for tests: a recording bot object can be injected instead of a
|
|
26
|
+
// real Bot (whose Api has no spy-able prototype in grammY 1.46+).
|
|
27
|
+
const bot = injected?.bot ?? new Bot(cfg.token);
|
|
28
|
+
// Whitelist middleware — silently drop strangers. Replying "Unauthorized"
|
|
29
|
+
// would confirm to anyone that this bot exists and is access-controlled.
|
|
30
|
+
bot.use(async (ctx, next) => {
|
|
31
|
+
if (!cfg.allowedUserIds.includes(ctx.from?.id ?? -1)) {
|
|
32
|
+
if (ctx.from)
|
|
33
|
+
log.warn(`rejected from ${ctx.from.id}`);
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
await next();
|
|
37
|
+
});
|
|
38
|
+
let messageHandler;
|
|
39
|
+
/** True while grammY polling is live — surfaced via status() for the channels panel. */
|
|
40
|
+
let pollingLive = false;
|
|
41
|
+
// Suggestion-chip tokens: callback ids → the chip text they send.
|
|
42
|
+
const sugTokens = new Map();
|
|
43
|
+
// "Generating" is derived from the relay's per-session abort registry, not a
|
|
44
|
+
// local flag. The relay returns immediately (the response arrives via the
|
|
45
|
+
// event hook), so a local flag would clear before generation finishes. The
|
|
46
|
+
// registry is set when a run starts and cleared on session.idle/error/abort.
|
|
47
|
+
// The gate is per-session: a busy session must not block input targeted at
|
|
48
|
+
// another (idle) session.
|
|
49
|
+
const fmtK = (n) => (n >= 1e6 ? `${(n / 1e6).toFixed(1)}M` : n >= 1e3 ? `${(n / 1e3).toFixed(1)}K` : String(n));
|
|
50
|
+
const targetSessionId = () => {
|
|
51
|
+
const raw = cfg.state.getPinnedSessionId() ?? cfg.state.getLastSessionId();
|
|
52
|
+
return raw ? cfg.state.normalizeSessionId(raw) : undefined;
|
|
53
|
+
};
|
|
54
|
+
const isGenerating = () => {
|
|
55
|
+
const sid = targetSessionId();
|
|
56
|
+
return sid ? cfg.state.hasActiveGeneration(sid) : false;
|
|
57
|
+
};
|
|
58
|
+
/** Abort the in-flight generation for the bot's target session (normalized pinned ?? last). Returns the resolved session id. */
|
|
59
|
+
function abortGeneration() {
|
|
60
|
+
const raw = cfg.state.getPinnedSessionId() ?? cfg.state.getLastSessionId();
|
|
61
|
+
if (!raw)
|
|
62
|
+
return undefined;
|
|
63
|
+
const sid = cfg.state.normalizeSessionId(raw);
|
|
64
|
+
cfg.state.getActiveAbort(sid)?.abort();
|
|
65
|
+
return sid;
|
|
66
|
+
}
|
|
67
|
+
// Wire text handler
|
|
68
|
+
/** Largest PhotoSize → getFile → download → base64 (TG photo intake). */
|
|
69
|
+
async function downloadLargestPhoto(api, sizes) {
|
|
70
|
+
const best = sizes.reduce((a, b) => ((b.width ?? 0) * (b.height ?? 0) > (a.width ?? 0) * (a.height ?? 0) ? b : a));
|
|
71
|
+
if (!best?.file_id)
|
|
72
|
+
throw new Error('photo has no file_id');
|
|
73
|
+
const file = await api.getFile(best.file_id);
|
|
74
|
+
if (!file.file_path)
|
|
75
|
+
throw new Error('getFile returned no file_path');
|
|
76
|
+
const res = await fetch(`https://api.telegram.org/file/bot${cfg.token}/${file.file_path}`);
|
|
77
|
+
if (!res.ok)
|
|
78
|
+
throw new Error(`download failed: ${res.status}`);
|
|
79
|
+
return { data: Buffer.from(await res.arrayBuffer()).toString('base64'), mimeType: 'image/jpeg' };
|
|
80
|
+
}
|
|
81
|
+
bot.use(async (ctx, next) => {
|
|
82
|
+
if (ctx.callbackQuery)
|
|
83
|
+
return next();
|
|
84
|
+
const m = ctx.message;
|
|
85
|
+
if (!m)
|
|
86
|
+
return next();
|
|
87
|
+
// grammY's Message union keeps `text` optional even after narrowing.
|
|
88
|
+
const text = 'text' in m ? m.text : undefined;
|
|
89
|
+
// P2b: photo intake — a photo (caption optional) becomes an image turn;
|
|
90
|
+
// anything else without text (stickers, voice, …) is not ours.
|
|
91
|
+
const photoSizes = !text && 'photo' in m && Array.isArray(m.photo) ? m.photo : undefined;
|
|
92
|
+
if (!text && !photoSizes)
|
|
93
|
+
return next();
|
|
94
|
+
if (text?.startsWith('/'))
|
|
95
|
+
return next();
|
|
96
|
+
let images;
|
|
97
|
+
if (photoSizes?.length) {
|
|
98
|
+
try {
|
|
99
|
+
images = [await downloadLargestPhoto(ctx.api, photoSizes)];
|
|
100
|
+
}
|
|
101
|
+
catch (err) {
|
|
102
|
+
log.warn('photo download failed', err.message);
|
|
103
|
+
void ctx.reply('❌ 图片接收失败,请稍后重试').catch(() => { });
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
// M4 reply-keyboard presses arrive as plain text — route them HERE, before
|
|
108
|
+
// the relay gate (grinev message-router pattern). Otherwise the button text
|
|
109
|
+
// would be forwarded to the model as if the user had typed it.
|
|
110
|
+
// Photo turns (no text) skip button routing — a caption must never press a
|
|
111
|
+
// keyboard button by accident.
|
|
112
|
+
if (text) {
|
|
113
|
+
if (AGENT_BUTTON_TEXT_PATTERN.test(text)) {
|
|
114
|
+
await openAgentsMenu(String(ctx.chat?.id ?? ctx.from?.id ?? ''));
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
if (MODEL_BUTTON_TEXT_PATTERN.test(text)) {
|
|
118
|
+
await openModelsMenu(String(ctx.chat?.id ?? ctx.from?.id ?? ''));
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
if (text === '📋 会话') {
|
|
122
|
+
await openSessionsMenu(String(ctx.chat?.id ?? ctx.from?.id ?? ''), 0);
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
if (CONTEXT_BUTTON_TEXT_PATTERN.test(text)) {
|
|
126
|
+
try {
|
|
127
|
+
// Same data source as the Inspector's CONTEXT panel (backend.getContext):
|
|
128
|
+
// used = last turn's input+output+reasoning+cache; max = model context window.
|
|
129
|
+
const target = targetSessionId();
|
|
130
|
+
if (!target) {
|
|
131
|
+
await ctx.reply('📊 没有活动会话');
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
const meta = await cfg.backend.getContext(target);
|
|
135
|
+
const tokens = (meta.tokens ?? {});
|
|
136
|
+
const used = tokens.used ?? 0;
|
|
137
|
+
const max = tokens.max ?? 0;
|
|
138
|
+
const pct = max > 0 ? Math.round((used / max) * 100) : 0;
|
|
139
|
+
let msg = `📊 上下文用量:${fmtK(used)} / ${fmtK(max)} tokens(${pct}%)`;
|
|
140
|
+
// Prompt-cache line, same semantics as the web Inspector: hit rate =
|
|
141
|
+
// cache.read / (input + cache.read) for the latest turn.
|
|
142
|
+
const cacheRead = tokens.cache?.read;
|
|
143
|
+
const cacheWrite = tokens.cache?.write;
|
|
144
|
+
const uncached = tokens.input;
|
|
145
|
+
if (typeof cacheRead === 'number' && typeof uncached === 'number' && uncached + cacheRead > 0) {
|
|
146
|
+
const hit = Math.round((cacheRead / (uncached + cacheRead)) * 100);
|
|
147
|
+
msg += `\n💾 缓存命中 ${hit}% · ${fmtK(cacheRead)} read / ${fmtK(cacheWrite ?? 0)} write`;
|
|
148
|
+
}
|
|
149
|
+
await ctx.reply(msg);
|
|
150
|
+
}
|
|
151
|
+
catch {
|
|
152
|
+
await ctx.reply('📊 上下文用量暂不可用');
|
|
153
|
+
}
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
if (!messageHandler)
|
|
158
|
+
return next();
|
|
159
|
+
// Prompt-queue (grinev M4 leftover, minimal slice): the relay serializes
|
|
160
|
+
// turns per session, so a mid-generation prompt is queued, not dropped —
|
|
161
|
+
// ack it so the sender isn't left staring at silence.
|
|
162
|
+
if (isGenerating()) {
|
|
163
|
+
void ctx.reply('⏳ 已排队——当前回复生成中,完成后自动执行').catch((err) => {
|
|
164
|
+
log.warn('failed to send queue ack', err.message);
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
const msg = {
|
|
168
|
+
userId: String(ctx.from?.id ?? ''),
|
|
169
|
+
chatId: String(ctx.chat?.id ?? ctx.from?.id ?? ''),
|
|
170
|
+
text: text ?? m.caption ?? '',
|
|
171
|
+
messageId: String(m.message_id),
|
|
172
|
+
images,
|
|
173
|
+
origin: 'telegram',
|
|
174
|
+
};
|
|
175
|
+
void messageHandler(msg);
|
|
176
|
+
});
|
|
177
|
+
// ── P2b-M3: grinev interaction mutex + permission flow ──
|
|
178
|
+
const interactionManager = new InteractionManager();
|
|
179
|
+
const permissionManager = new PermissionManager(interactionManager);
|
|
180
|
+
let permissionFlow;
|
|
181
|
+
let questionFlow;
|
|
182
|
+
/** Latest reply-keyboard payload, refreshed by whoever has fresh data. */
|
|
183
|
+
let keyboardData = {
|
|
184
|
+
agentName: 'opencode',
|
|
185
|
+
modelLabel: 'default',
|
|
186
|
+
};
|
|
187
|
+
// Legacy approval state (fallback path + TTL sweep kept from M1).
|
|
188
|
+
const pendingApprovals = new Map();
|
|
189
|
+
// Short token → permissionId, so approve:* callback_data stays under
|
|
190
|
+
// Telegram's 64-byte limit (same sha1 pattern as the workspace tokens).
|
|
191
|
+
const approvalTokens = new Map();
|
|
192
|
+
const approvalToken = (permId) => {
|
|
193
|
+
const t = createHash('sha1').update(`approve:${permId}`).digest('base64url').slice(0, 16);
|
|
194
|
+
approvalTokens.set(t, permId);
|
|
195
|
+
return t;
|
|
196
|
+
};
|
|
197
|
+
// Entries are normally removed on click or on the permission.replied event;
|
|
198
|
+
// if that event is lost the entry would leak forever. Sweep expired ones.
|
|
199
|
+
const APPROVAL_TTL_MS = 30 * 60 * 1000;
|
|
200
|
+
const approvalSweep = setInterval(() => {
|
|
201
|
+
const now = Date.now();
|
|
202
|
+
for (const [permId, p] of pendingApprovals) {
|
|
203
|
+
if (now - p.createdAt > APPROVAL_TTL_MS) {
|
|
204
|
+
pendingApprovals.delete(permId);
|
|
205
|
+
log.warn(`pending approval ${permId} expired (TTL), dropping`);
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
// Tokens whose approval is gone (clicked, replied, expired) are dead weight.
|
|
209
|
+
for (const [t, permId] of approvalTokens) {
|
|
210
|
+
if (!pendingApprovals.has(permId))
|
|
211
|
+
approvalTokens.delete(t);
|
|
212
|
+
}
|
|
213
|
+
}, 60_000);
|
|
214
|
+
approvalSweep.unref?.();
|
|
215
|
+
// P2b-M3: grinev permission:* callbacks — the mutex flow owns these.
|
|
216
|
+
bot.callbackQuery(/^permission:(once|always|reject)$/, async (ctx) => {
|
|
217
|
+
const decision = ctx.match[1];
|
|
218
|
+
if (!permissionFlow) {
|
|
219
|
+
await ctx.answerCallbackQuery('尚未就绪').catch(() => { });
|
|
220
|
+
return;
|
|
221
|
+
}
|
|
222
|
+
await permissionFlow.onDecision(ctx, decision).catch((err) => {
|
|
223
|
+
log.error('permission decision failed', err);
|
|
224
|
+
ctx.answerCallbackQuery('处理失败').catch(() => { });
|
|
225
|
+
});
|
|
226
|
+
});
|
|
227
|
+
// ── M10: question-tool callbacks (q:<tok>:o:<idx> / :ok / :rej) ──
|
|
228
|
+
bot.callbackQuery(/^q:([0-9a-zA-Z_-]{16}):o:(\d+)$/, async (ctx) => {
|
|
229
|
+
const m = ctx.match;
|
|
230
|
+
if (!questionFlow) {
|
|
231
|
+
await ctx.answerCallbackQuery('尚未就绪').catch(() => { });
|
|
232
|
+
return;
|
|
233
|
+
}
|
|
234
|
+
await questionFlow.onOption(ctx, m[1], parseInt(m[2], 10)).catch((err) => {
|
|
235
|
+
log.error('question option failed', err);
|
|
236
|
+
ctx.answerCallbackQuery('处理失败').catch(() => { });
|
|
237
|
+
});
|
|
238
|
+
});
|
|
239
|
+
bot.callbackQuery(/^q:([0-9a-zA-Z_-]{16}):ok$/, async (ctx) => {
|
|
240
|
+
if (!questionFlow) {
|
|
241
|
+
await ctx.answerCallbackQuery('尚未就绪').catch(() => { });
|
|
242
|
+
return;
|
|
243
|
+
}
|
|
244
|
+
await questionFlow.onSubmit(ctx, ctx.match[1]).catch((err) => {
|
|
245
|
+
log.error('question submit failed', err);
|
|
246
|
+
ctx.answerCallbackQuery('处理失败').catch(() => { });
|
|
247
|
+
});
|
|
248
|
+
});
|
|
249
|
+
bot.callbackQuery(/^q:([0-9a-zA-Z_-]{16}):rej$/, async (ctx) => {
|
|
250
|
+
if (!questionFlow) {
|
|
251
|
+
await ctx.answerCallbackQuery('尚未就绪').catch(() => { });
|
|
252
|
+
return;
|
|
253
|
+
}
|
|
254
|
+
await questionFlow.onReject(ctx, ctx.match[1]).catch((err) => {
|
|
255
|
+
log.error('question reject failed', err);
|
|
256
|
+
ctx.answerCallbackQuery('处理失败').catch(() => { });
|
|
257
|
+
});
|
|
258
|
+
});
|
|
259
|
+
// ── M4: inline menus (sessions paging / agents / models) ──
|
|
260
|
+
let sessionsCache = [];
|
|
261
|
+
let activeMenuMessageId;
|
|
262
|
+
bot.callbackQuery(/^menu:spage:(\d+)$/, async (ctx) => {
|
|
263
|
+
const p = Number(ctx.match[1]);
|
|
264
|
+
const { text, keyboard } = renderSessionsMenu(sessionsCache, p);
|
|
265
|
+
await editMenu(bot.api, ctx.chat?.id ?? chatIdOf(), ctx.callbackQuery.message?.message_id, text, keyboard);
|
|
266
|
+
await ctx.answerCallbackQuery();
|
|
267
|
+
});
|
|
268
|
+
bot.callbackQuery(/^menu:session:(.+)$/, async (ctx) => {
|
|
269
|
+
const sid = ctx.match[1];
|
|
270
|
+
cfg.state.setPinnedSessionId(sid);
|
|
271
|
+
await ctx.answerCallbackQuery(`已切换:…${sid.slice(-8)}`);
|
|
272
|
+
try {
|
|
273
|
+
await ctx.editMessageText(`📍 已切换会话 <code>…${sid.slice(-8)}</code>`, { parse_mode: 'HTML' });
|
|
274
|
+
}
|
|
275
|
+
catch { }
|
|
276
|
+
});
|
|
277
|
+
bot.callbackQuery(/^menu:agent:(.+)$/, async (ctx) => {
|
|
278
|
+
const name = ctx.match[1];
|
|
279
|
+
cfg.state.setNextAgent(name);
|
|
280
|
+
await ctx.answerCallbackQuery(`Agent → ${name}`);
|
|
281
|
+
try {
|
|
282
|
+
await ctx.editMessageText(`🤖 Agent 覆盖:<b>${name}</b>`, { parse_mode: 'HTML' });
|
|
283
|
+
}
|
|
284
|
+
catch { }
|
|
285
|
+
});
|
|
286
|
+
bot.callbackQuery(/^menu:model:([^:]+):(.+)$/, async (ctx) => {
|
|
287
|
+
const providerID = ctx.match[1];
|
|
288
|
+
const modelID = ctx.match[2];
|
|
289
|
+
cfg.state.setNextModel({ providerID, modelID });
|
|
290
|
+
await ctx.answerCallbackQuery(`模型 → ${providerID}/${modelID}`);
|
|
291
|
+
try {
|
|
292
|
+
await ctx.editMessageText(`🧠 模型覆盖:<b>${providerID}/${modelID}</b>`, { parse_mode: 'HTML' });
|
|
293
|
+
}
|
|
294
|
+
catch { }
|
|
295
|
+
});
|
|
296
|
+
bot.callbackQuery('menu:agentclear', async (ctx) => {
|
|
297
|
+
cfg.state.setNextAgent(undefined);
|
|
298
|
+
await ctx.answerCallbackQuery('已清除 agent 覆盖');
|
|
299
|
+
try {
|
|
300
|
+
await ctx.editMessageText('Agent 覆盖已清除', { parse_mode: 'HTML' });
|
|
301
|
+
}
|
|
302
|
+
catch { }
|
|
303
|
+
});
|
|
304
|
+
bot.callbackQuery('menu:noop', async (ctx) => { await ctx.answerCallbackQuery(); });
|
|
305
|
+
// Web C4 parity: two-step session delete from the sessions menu.
|
|
306
|
+
bot.callbackQuery(/^menu:sdel:(.+)$/, async (ctx) => {
|
|
307
|
+
const sid = ctx.match[1];
|
|
308
|
+
const kb = new InlineKeyboard()
|
|
309
|
+
.text('🗑 确认删除', `menu:sdelok:${sid}`)
|
|
310
|
+
.text('取消', 'menu:spage:0');
|
|
311
|
+
await ctx.answerCallbackQuery();
|
|
312
|
+
try {
|
|
313
|
+
await ctx.editMessageText(`⚠️ 删除会话 …${sid.slice(-8)}?其全部消息将不可恢复。`, { parse_mode: 'HTML', reply_markup: kb });
|
|
314
|
+
}
|
|
315
|
+
catch { }
|
|
316
|
+
});
|
|
317
|
+
bot.callbackQuery(/^menu:sdelok:(.+)$/, async (ctx) => {
|
|
318
|
+
const sid = ctx.match[1];
|
|
319
|
+
try {
|
|
320
|
+
await cfg.backend.deleteSession(sid);
|
|
321
|
+
if (cfg.state.getPinnedSessionId() === sid)
|
|
322
|
+
cfg.state.setPinnedSessionId(undefined);
|
|
323
|
+
await ctx.answerCallbackQuery('🗑 已删除');
|
|
324
|
+
try {
|
|
325
|
+
await ctx.editMessageText('🗑 会话已删除。');
|
|
326
|
+
}
|
|
327
|
+
catch { }
|
|
328
|
+
await openSessionsMenu(String(ctx.chat?.id ?? ctx.from?.id ?? ''), 0);
|
|
329
|
+
}
|
|
330
|
+
catch (err) {
|
|
331
|
+
await ctx.answerCallbackQuery(`删除失败:${err.message.slice(0, 60)}`);
|
|
332
|
+
}
|
|
333
|
+
});
|
|
334
|
+
// ── Regenerate (web C3 parity): re-send the last user message of the session.
|
|
335
|
+
bot.callbackQuery(/^retry:(.+)$/, async (ctx) => {
|
|
336
|
+
const sid = ctx.match[1];
|
|
337
|
+
try {
|
|
338
|
+
const cards = await cfg.backend.getHistory(sid);
|
|
339
|
+
const lastUser = [...cards].reverse().find((c) => c.kind === 'user');
|
|
340
|
+
const text = lastUser?.text ?? '';
|
|
341
|
+
if (!text.trim()) {
|
|
342
|
+
await ctx.answerCallbackQuery('没有可重发的内容');
|
|
343
|
+
return;
|
|
344
|
+
}
|
|
345
|
+
await messageHandler?.({
|
|
346
|
+
userId: String(ctx.from?.id ?? ''),
|
|
347
|
+
chatId: String(ctx.chat?.id ?? ctx.from?.id ?? ''),
|
|
348
|
+
text,
|
|
349
|
+
messageId: `retry_${Date.now()}`,
|
|
350
|
+
origin: 'telegram',
|
|
351
|
+
});
|
|
352
|
+
await ctx.answerCallbackQuery('↻ 已重发上一条');
|
|
353
|
+
}
|
|
354
|
+
catch (err) {
|
|
355
|
+
await ctx.answerCallbackQuery(`重发失败:${err.message.slice(0, 80)}`);
|
|
356
|
+
}
|
|
357
|
+
});
|
|
358
|
+
// Suggestion chips (web C2 parity): tap = send directly (no draft box on TG).
|
|
359
|
+
bot.callbackQuery(/^sug:(\d+)$/, async (ctx) => {
|
|
360
|
+
const text = sugTokens.get(Number(ctx.match[1]));
|
|
361
|
+
if (!text) {
|
|
362
|
+
await ctx.answerCallbackQuery('该建议已过期');
|
|
363
|
+
return;
|
|
364
|
+
}
|
|
365
|
+
await messageHandler?.({
|
|
366
|
+
userId: String(ctx.from?.id ?? ''),
|
|
367
|
+
chatId: String(ctx.chat?.id ?? ctx.from?.id ?? ''),
|
|
368
|
+
text,
|
|
369
|
+
messageId: `sug_${Date.now()}`,
|
|
370
|
+
origin: 'telegram',
|
|
371
|
+
});
|
|
372
|
+
await ctx.answerCallbackQuery('已发送');
|
|
373
|
+
});
|
|
374
|
+
/** Open the agents menu as an editable message. */
|
|
375
|
+
async function openAgentsMenu(chatId) {
|
|
376
|
+
try {
|
|
377
|
+
const agents = await cfg.backend.getAgents(cfg.opencodeProject);
|
|
378
|
+
const current = cfg.state.getNextAgent();
|
|
379
|
+
const { text, keyboard } = renderAgentsMenu(agents.map(a => ({ name: a.name, model: a.model })), current);
|
|
380
|
+
const sent = await bot.api.sendMessage(chatId, text, { parse_mode: 'HTML', reply_markup: keyboard });
|
|
381
|
+
activeMenuMessageId = sent.message_id;
|
|
382
|
+
}
|
|
383
|
+
catch (err) {
|
|
384
|
+
log.warn('openAgentsMenu failed', err.message);
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
/** Open the models menu as an editable message. */
|
|
388
|
+
async function openModelsMenu(chatId) {
|
|
389
|
+
try {
|
|
390
|
+
const providers = await cfg.backend.getModels(cfg.opencodeProject);
|
|
391
|
+
const { text, keyboard } = renderModelsMenu(providers);
|
|
392
|
+
const sent = await bot.api.sendMessage(chatId, text, { parse_mode: 'HTML', reply_markup: keyboard });
|
|
393
|
+
activeMenuMessageId = sent.message_id;
|
|
394
|
+
}
|
|
395
|
+
catch (err) {
|
|
396
|
+
log.warn('openModelsMenu failed', err.message);
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
/** Open the paginated sessions menu. */
|
|
400
|
+
async function openSessionsMenu(chatId, page) {
|
|
401
|
+
try {
|
|
402
|
+
sessionsCache = (await cfg.backend.listSessionSummaries()).map(s => ({
|
|
403
|
+
id: s.id, title: s.title, directory: s.directory, lastActiveAt: s.lastActiveAt,
|
|
404
|
+
}));
|
|
405
|
+
const active = cfg.state.getPinnedSessionId() ?? cfg.state.getLastSessionId();
|
|
406
|
+
const { text, keyboard } = renderSessionsMenu(sessionsCache, page, active);
|
|
407
|
+
const sent = await bot.api.sendMessage(chatId, text, { parse_mode: 'HTML', reply_markup: keyboard });
|
|
408
|
+
activeMenuMessageId = sent.message_id;
|
|
409
|
+
}
|
|
410
|
+
catch (err) {
|
|
411
|
+
log.warn('openSessionsMenu failed', err.message);
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
const chatIdOf = () => String(cfg.allowedUserIds[0]);
|
|
415
|
+
// ── M4: reply keyboard + hears routers (grinev signature UX) ──
|
|
416
|
+
const sendMainKeyboard = async (ctx) => {
|
|
417
|
+
await ctx.reply('键盘已更新', { reply_markup: buildMainKeyboard(keyboardData) }).catch((err) => {
|
|
418
|
+
log.warn('send keyboard failed', err.message);
|
|
419
|
+
});
|
|
420
|
+
};
|
|
421
|
+
const refreshKeyboardData = async () => {
|
|
422
|
+
try {
|
|
423
|
+
const target = targetSessionId();
|
|
424
|
+
const meta = target ? await cfg.backend.getContext(target) : undefined;
|
|
425
|
+
const agents = cfg.backend.capabilities.catalog ? await cfg.backend.getAgents(cfg.opencodeProject).catch(() => []) : [];
|
|
426
|
+
keyboardData = {
|
|
427
|
+
agentName: meta?.agent ?? keyboardData.agentName,
|
|
428
|
+
modelLabel: meta?.model ?? keyboardData.modelLabel,
|
|
429
|
+
context: meta?.tokens?.used != null && meta?.tokens?.max
|
|
430
|
+
? { used: (meta?.tokens).used, limit: (meta?.tokens).max }
|
|
431
|
+
: keyboardData.context,
|
|
432
|
+
};
|
|
433
|
+
void agents;
|
|
434
|
+
}
|
|
435
|
+
catch { /* keep previous */ }
|
|
436
|
+
};
|
|
437
|
+
// Button presses arrive as ordinary text messages — match by pattern.
|
|
438
|
+
// Register commands + callbacks
|
|
439
|
+
registerHandlers({
|
|
440
|
+
bot,
|
|
441
|
+
backend: cfg.backend,
|
|
442
|
+
baseUrl: cfg.baseUrl ?? '',
|
|
443
|
+
state: cfg.state,
|
|
444
|
+
isGenerating,
|
|
445
|
+
abortGeneration,
|
|
446
|
+
pendingApprovals,
|
|
447
|
+
approvalTokens,
|
|
448
|
+
opencodeProject: cfg.opencodeProject,
|
|
449
|
+
scheduler: cfg.scheduler,
|
|
450
|
+
channels: cfg.channels,
|
|
451
|
+
});
|
|
452
|
+
// Error catch-all — grammY wraps handler errors in BotError (err.error).
|
|
453
|
+
bot.catch((err) => {
|
|
454
|
+
const e = err.error ?? err;
|
|
455
|
+
log.error('grammY catch-all', e);
|
|
456
|
+
bot.api.sendMessage(String(cfg.allowedUserIds[0]), `Internal error: ${e.message}`).catch(() => { });
|
|
457
|
+
});
|
|
458
|
+
// Per-session renderers. The StreamingRenderer owns thinking/streaming/
|
|
459
|
+
// assistant/error cards (grinev pipeline: live-edited stream message with
|
|
460
|
+
// progressive throttling); the legacy renderer stays for user/info echoes.
|
|
461
|
+
const renderers = new Map();
|
|
462
|
+
let streamingRenderer;
|
|
463
|
+
function getRenderer(sessionId, chatId) {
|
|
464
|
+
let r = renderers.get(sessionId);
|
|
465
|
+
if (!r) {
|
|
466
|
+
r = new TelegramSessionRenderer({ chatId, sessionId, bot: bot.api, chunkSoftLimit: cfg.tgChunkSoftLimit });
|
|
467
|
+
renderers.set(sessionId, r);
|
|
468
|
+
}
|
|
469
|
+
return r;
|
|
470
|
+
}
|
|
471
|
+
function labelFor(response) {
|
|
472
|
+
switch (response) {
|
|
473
|
+
case 'once': return 'Allowed (once)';
|
|
474
|
+
case 'always': return 'Always Allowed';
|
|
475
|
+
case 'reject': return 'Rejected';
|
|
476
|
+
default: return response ?? 'Handled';
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
let cardBusRef;
|
|
480
|
+
return {
|
|
481
|
+
name: 'telegram',
|
|
482
|
+
capabilities: CAPS,
|
|
483
|
+
async start(deps) {
|
|
484
|
+
const { cardBus } = deps;
|
|
485
|
+
cardBusRef = cardBus;
|
|
486
|
+
const chatId = String(cfg.allowedUserIds[0]);
|
|
487
|
+
streamingRenderer = new StreamingRenderer({ api: bot.api, chatId, granularity: () => cfg.channels?.()?.replyGranularity ?? 'detailed' });
|
|
488
|
+
permissionFlow = new PermissionFlow({
|
|
489
|
+
interactionManager,
|
|
490
|
+
permissionManager,
|
|
491
|
+
resolve: async (requestIds, decision) => {
|
|
492
|
+
// All grouped requests share one signature → one session; take the
|
|
493
|
+
// session from the pressed message's request when available.
|
|
494
|
+
for (const requestId of requestIds) {
|
|
495
|
+
const sid = targetSessionId();
|
|
496
|
+
if (!sid)
|
|
497
|
+
continue;
|
|
498
|
+
await cfg.backend.resolvePermission(sid, requestId, decision).catch((err) => {
|
|
499
|
+
log.warn(`resolvePermission failed for ${requestId}`, err.message);
|
|
500
|
+
});
|
|
501
|
+
}
|
|
502
|
+
},
|
|
503
|
+
sessionIdOf: (r) => r.sessionID,
|
|
504
|
+
}, chatId);
|
|
505
|
+
questionFlow = new QuestionFlow({
|
|
506
|
+
interactionManager,
|
|
507
|
+
answer: async (sid, requestId, answers) => await cfg.backend.answerQuestion?.(sid, requestId, answers),
|
|
508
|
+
reject: async (sid, requestId) => await cfg.backend.rejectQuestion?.(sid, requestId),
|
|
509
|
+
}, chatId);
|
|
510
|
+
// Tier2 suggestion chips (web C2 parity): the relay generates them
|
|
511
|
+
// fire-and-forget AFTER finalize and parks them in state — poll twice,
|
|
512
|
+
// then send up to 3 as an inline keyboard. Tapping a chip SENDS it
|
|
513
|
+
// (Telegram has no draft box — the web "fill, don't send" semantics
|
|
514
|
+
// doesn't exist here; user-approved deviation).
|
|
515
|
+
const suggestionTimers = new Map();
|
|
516
|
+
let sugSeq = 0;
|
|
517
|
+
function scheduleSuggestions(sid) {
|
|
518
|
+
const prev = suggestionTimers.get(sid);
|
|
519
|
+
if (prev)
|
|
520
|
+
prev.forEach(clearTimeout);
|
|
521
|
+
const timers = [3_000, 9_000].map((ms) => setTimeout(async () => {
|
|
522
|
+
const items = cfg.state.getSessionSuggestions?.(sid) ?? [];
|
|
523
|
+
if (items.length === 0 || suggestionTimers.get(sid) !== timers)
|
|
524
|
+
return;
|
|
525
|
+
suggestionTimers.delete(sid);
|
|
526
|
+
const kb = new InlineKeyboard();
|
|
527
|
+
for (const item of items.slice(0, 3)) {
|
|
528
|
+
const tok = ++sugSeq;
|
|
529
|
+
sugTokens.set(tok, item);
|
|
530
|
+
kb.text(`💡 ${item.slice(0, 64)}`, `sug:${tok}`).row();
|
|
531
|
+
}
|
|
532
|
+
await bot.api
|
|
533
|
+
.sendMessage(chatId, '💡 建议下一步(点按直接发送)', { reply_markup: kb })
|
|
534
|
+
.catch((err) => log.warn('suggestions send failed', err.message));
|
|
535
|
+
}, ms));
|
|
536
|
+
suggestionTimers.set(sid, timers);
|
|
537
|
+
}
|
|
538
|
+
cardBus.subscribeAll((card) => {
|
|
539
|
+
if (!('sessionId' in card) || !card.sessionId)
|
|
540
|
+
return;
|
|
541
|
+
log.info(`[telegram] card received: kind=${card.kind} sessionId=${card.sessionId}`);
|
|
542
|
+
const sr = streamingRenderer;
|
|
543
|
+
if (sr && (card.kind === 'streaming' || card.kind === 'thinking' || card.kind === 'assistant' || card.kind === 'error')) {
|
|
544
|
+
// P2b-M2: grinev streaming pipeline owns the turn lifecycle.
|
|
545
|
+
void (async () => {
|
|
546
|
+
try {
|
|
547
|
+
if (card.kind === 'thinking')
|
|
548
|
+
return; // the stream message is the placeholder
|
|
549
|
+
if (card.kind === 'streaming') {
|
|
550
|
+
const text = card.blocks.filter((b) => b.type === 'text').map((b) => b.text ?? '').join('');
|
|
551
|
+
sr.onStreaming(card.sessionId, card.messageId, text);
|
|
552
|
+
return;
|
|
553
|
+
}
|
|
554
|
+
if (card.kind === 'assistant') {
|
|
555
|
+
// P2c parity: regenerate action bar on the final message…
|
|
556
|
+
const actions = isEphemeralSession(card.sessionId)
|
|
557
|
+
? undefined
|
|
558
|
+
: { text: '⌨️ 操作', keyboard: new InlineKeyboard().text('↻ 重发上一条', `retry:${card.sessionId}`) };
|
|
559
|
+
await sr.onFinalize(card.sessionId, card.blocks, card.meta, actions);
|
|
560
|
+
// …and Tier2 suggestion chips as a follow-up message (they are
|
|
561
|
+
// generated fire-and-forget after finalize — poll briefly).
|
|
562
|
+
if (!isEphemeralSession(card.sessionId))
|
|
563
|
+
scheduleSuggestions(card.sessionId);
|
|
564
|
+
return;
|
|
565
|
+
}
|
|
566
|
+
if (card.kind === 'error') {
|
|
567
|
+
await sr.onError(card.sessionId, card.message ?? 'error');
|
|
568
|
+
return;
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
catch (err) {
|
|
572
|
+
log.error(`[telegram] streaming onCard failed for ${card.kind}`, err);
|
|
573
|
+
}
|
|
574
|
+
})();
|
|
575
|
+
if (card.kind === 'assistant' || card.kind === 'error')
|
|
576
|
+
renderers.delete(card.sessionId);
|
|
577
|
+
// grinev UX 对照表:后台会话完成通知 —— a turn finished in a session
|
|
578
|
+
// other than the TG-target one gets a heads-up with a switch button.
|
|
579
|
+
if (card.kind === 'assistant' && permissionFlow) {
|
|
580
|
+
const target = targetSessionId();
|
|
581
|
+
if (target && card.sessionId !== target && !isEphemeralSession(card.sessionId)) {
|
|
582
|
+
const bsid = card.sessionId;
|
|
583
|
+
const bk = new InlineKeyboard().text('📥 打开该会话', `menu:session:${bsid}`);
|
|
584
|
+
void bot.api
|
|
585
|
+
.sendMessage(chatId, `ℹ️ 后台会话已完成:…${bsid.slice(-8)}`, { reply_markup: bk })
|
|
586
|
+
.catch((err) => log.warn('background notice failed', err.message));
|
|
587
|
+
}
|
|
588
|
+
}
|
|
589
|
+
return;
|
|
590
|
+
}
|
|
591
|
+
const r = getRenderer(card.sessionId, chatId);
|
|
592
|
+
r.onCard(card).catch((err) => {
|
|
593
|
+
log.error(`[telegram] onCard failed for ${card.kind}`, err);
|
|
594
|
+
});
|
|
595
|
+
if (card.kind === 'assistant' || card.kind === 'error') {
|
|
596
|
+
renderers.delete(card.sessionId);
|
|
597
|
+
}
|
|
598
|
+
});
|
|
599
|
+
// Deliver the persistent reply keyboard once per boot.
|
|
600
|
+
await refreshKeyboardData().catch(() => { });
|
|
601
|
+
await bot.api
|
|
602
|
+
.sendMessage(chatId, 'ocrc 已就绪', { reply_markup: buildMainKeyboard(keyboardData) })
|
|
603
|
+
.catch((err) => log.warn('boot keyboard send failed', err.message));
|
|
604
|
+
const MAX_CONFLICT = 8;
|
|
605
|
+
const MAX_RETRIES = 10;
|
|
606
|
+
while (true) {
|
|
607
|
+
let attempt = 0;
|
|
608
|
+
let conflictCount = 0;
|
|
609
|
+
for (let retryCount = 0; retryCount < MAX_RETRIES; retryCount++) {
|
|
610
|
+
try {
|
|
611
|
+
await bot.start({ onStart: (me) => { pollingLive = true; log.info(`bot polling as @${me.username}`); } });
|
|
612
|
+
log.info('bot polling ended cleanly');
|
|
613
|
+
pollingLive = false;
|
|
614
|
+
return;
|
|
615
|
+
}
|
|
616
|
+
catch (err) {
|
|
617
|
+
const code = errorCodeOf(err);
|
|
618
|
+
if (code === 409) {
|
|
619
|
+
if (++conflictCount >= MAX_CONFLICT) {
|
|
620
|
+
log.error('FATAL: Telegram 409 conflict persisted — another instance is polling this bot token; transport stopped');
|
|
621
|
+
throw new Error('Telegram 409 persisted');
|
|
622
|
+
}
|
|
623
|
+
log.warn(`409 #${conflictCount}, releasing stale lock and retrying`);
|
|
624
|
+
try {
|
|
625
|
+
await bot.stop();
|
|
626
|
+
}
|
|
627
|
+
catch { /* bot may not have fully started */ }
|
|
628
|
+
try {
|
|
629
|
+
await bot.api.raw.getUpdates({ offset: -1, timeout: 0, limit: 1 });
|
|
630
|
+
}
|
|
631
|
+
catch { /* ignore — if this fails the next launch will tell us */ }
|
|
632
|
+
await new Promise((r) => setTimeout(r, 5000));
|
|
633
|
+
}
|
|
634
|
+
else if (code === 401) {
|
|
635
|
+
log.error('FATAL: bot token invalid or revoked (401) — Telegram transport stopped; fix the token and restart');
|
|
636
|
+
return;
|
|
637
|
+
}
|
|
638
|
+
else {
|
|
639
|
+
attempt += 1;
|
|
640
|
+
const delay = Math.min(1000 * 2 ** attempt, 30000);
|
|
641
|
+
log.error(`bot.start failed (attempt ${attempt}/${MAX_RETRIES})`, err?.message ?? err);
|
|
642
|
+
await new Promise((r) => setTimeout(r, delay));
|
|
643
|
+
}
|
|
644
|
+
}
|
|
645
|
+
}
|
|
646
|
+
log.warn(`bot.start: exhausted ${MAX_RETRIES} retries, restarting in 60s`);
|
|
647
|
+
await new Promise((r) => setTimeout(r, 60_000));
|
|
648
|
+
}
|
|
649
|
+
},
|
|
650
|
+
async stop() {
|
|
651
|
+
pollingLive = false;
|
|
652
|
+
await bot.stop();
|
|
653
|
+
clearInterval(approvalSweep);
|
|
654
|
+
},
|
|
655
|
+
status() {
|
|
656
|
+
return { connected: pollingLive, username: bot.botInfo?.username };
|
|
657
|
+
},
|
|
658
|
+
async send(_chatId, _card) {
|
|
659
|
+
throw new Error('Transport.send not implemented for Telegram in v0.5.0');
|
|
660
|
+
},
|
|
661
|
+
onMessage(h) { messageHandler = h; },
|
|
662
|
+
onCommand(_name, _h) { },
|
|
663
|
+
onButtonClick(_h) { },
|
|
664
|
+
/** Plugin mode: handle permission events from the opencode event hook. */
|
|
665
|
+
async handlePluginPermissionEvent(event) {
|
|
666
|
+
const props = event.properties ?? {};
|
|
667
|
+
if (event.type === 'permission.updated' || event.type === 'permission.asked') {
|
|
668
|
+
const permId = props.id;
|
|
669
|
+
const title = props.title ?? props.permission ?? 'Unknown operation';
|
|
670
|
+
const sessionId = props.sessionID;
|
|
671
|
+
if (!permId || !sessionId) {
|
|
672
|
+
log.warn(`Plugin permission: missing id or sessionID`, props);
|
|
673
|
+
return;
|
|
674
|
+
}
|
|
675
|
+
// P2b-M3: grinev mutex flow (merge + slot + generation) replaces the
|
|
676
|
+
// plain three-button card. The legacy pendingApprovals/token maps stay
|
|
677
|
+
// wired for the legacy approve:* callbacks (kept for fallback).
|
|
678
|
+
const fullSid = cfg.state.normalizeSessionId(sessionId);
|
|
679
|
+
const request = {
|
|
680
|
+
id: permId,
|
|
681
|
+
sessionID: fullSid,
|
|
682
|
+
permission: props.permission ?? title,
|
|
683
|
+
patterns: props.patterns ?? [],
|
|
684
|
+
metadata: props.args ?? {},
|
|
685
|
+
always: [],
|
|
686
|
+
};
|
|
687
|
+
if (permissionFlow) {
|
|
688
|
+
await permissionFlow.present(bot.api, request);
|
|
689
|
+
}
|
|
690
|
+
else {
|
|
691
|
+
const escaped = title.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
692
|
+
const text = `⚠️ <b>Permission Required</b>\n\n<code>${escaped}</code>`;
|
|
693
|
+
const token = approvalToken(permId);
|
|
694
|
+
const keyboard = {
|
|
695
|
+
...inlineKeyboard([
|
|
696
|
+
[
|
|
697
|
+
btn('✅ Once', `approve:once:${token}`),
|
|
698
|
+
btn('🔓 Always', `approve:always:${token}`),
|
|
699
|
+
btn('❌ Reject', `approve:reject:${token}`),
|
|
700
|
+
],
|
|
701
|
+
]),
|
|
702
|
+
parse_mode: 'HTML',
|
|
703
|
+
};
|
|
704
|
+
try {
|
|
705
|
+
const msg = await bot.api.sendMessage(String(cfg.allowedUserIds[0]), text, keyboard);
|
|
706
|
+
pendingApprovals.set(permId, {
|
|
707
|
+
sessionId,
|
|
708
|
+
permissionId: permId,
|
|
709
|
+
messageId: msg.message_id,
|
|
710
|
+
title,
|
|
711
|
+
createdAt: Date.now(),
|
|
712
|
+
});
|
|
713
|
+
log.info(`[plugin] approval card sent permId=${permId}`);
|
|
714
|
+
}
|
|
715
|
+
catch (err) {
|
|
716
|
+
log.error('[plugin] failed to send approval card', err);
|
|
717
|
+
}
|
|
718
|
+
}
|
|
719
|
+
// Publish to CardBus so Web UI can also show the approval modal
|
|
720
|
+
try {
|
|
721
|
+
cardBusRef?.publish({
|
|
722
|
+
kind: 'approval',
|
|
723
|
+
sessionId: cfg.state.normalizeSessionId(sessionId),
|
|
724
|
+
title,
|
|
725
|
+
args: props.args ?? props.permission ?? {},
|
|
726
|
+
requestId: permId,
|
|
727
|
+
});
|
|
728
|
+
}
|
|
729
|
+
catch (err) {
|
|
730
|
+
log.warn('[plugin] failed to publish approval card', err);
|
|
731
|
+
}
|
|
732
|
+
}
|
|
733
|
+
if (event.type === 'permission.replied') {
|
|
734
|
+
const permId = props.permissionID ?? props.requestID;
|
|
735
|
+
const response = props.response ?? props.reply;
|
|
736
|
+
if (!permId)
|
|
737
|
+
return;
|
|
738
|
+
// P2b-M3: 外部已答清理 via the mutex flow when it owns the prompt.
|
|
739
|
+
if (permissionFlow) {
|
|
740
|
+
await permissionFlow.onExternalReply(bot.api, permId, response).catch((err) => {
|
|
741
|
+
log.warn('external permission cleanup failed', err.message);
|
|
742
|
+
});
|
|
743
|
+
}
|
|
744
|
+
const p = pendingApprovals.get(permId);
|
|
745
|
+
if (!p)
|
|
746
|
+
return;
|
|
747
|
+
pendingApprovals.delete(permId);
|
|
748
|
+
try {
|
|
749
|
+
const display = labelFor(response);
|
|
750
|
+
await bot.api.editMessageText(String(cfg.allowedUserIds[0]), p.messageId, `${display} (from TUI)\n\n${esc(p.title)}`, { parse_mode: 'HTML' });
|
|
751
|
+
}
|
|
752
|
+
catch (err) {
|
|
753
|
+
log.warn(`[plugin] couldn't update card after TUI reply: ${err.message}`);
|
|
754
|
+
}
|
|
755
|
+
}
|
|
756
|
+
},
|
|
757
|
+
/** M10: handle question events from the opencode event hook. */
|
|
758
|
+
async handlePluginQuestionEvent(event) {
|
|
759
|
+
const props = event.properties ?? {};
|
|
760
|
+
// The web question card is published by the plugin entry (single source);
|
|
761
|
+
// this method only drives the Telegram side.
|
|
762
|
+
if (event.type === 'question.asked') {
|
|
763
|
+
const requestId = props.id;
|
|
764
|
+
const sessionId = props.sessionID;
|
|
765
|
+
if (!requestId || !sessionId || !questionFlow)
|
|
766
|
+
return;
|
|
767
|
+
const questions = (Array.isArray(props.questions) ? props.questions : []).map((q) => ({
|
|
768
|
+
question: String(q?.question ?? ''),
|
|
769
|
+
header: typeof q?.header === 'string' && q.header ? q.header : undefined,
|
|
770
|
+
multiple: q?.multiple === true,
|
|
771
|
+
custom: q?.custom === true,
|
|
772
|
+
options: (Array.isArray(q?.options) ? q.options : [])
|
|
773
|
+
.filter((o) => typeof o?.label === 'string' && o.label)
|
|
774
|
+
.map((o) => ({ label: String(o.label), description: typeof o?.description === 'string' ? o.description : undefined })),
|
|
775
|
+
}));
|
|
776
|
+
await questionFlow.present(bot.api, {
|
|
777
|
+
requestId,
|
|
778
|
+
sessionId: cfg.state.normalizeSessionId(sessionId),
|
|
779
|
+
questions,
|
|
780
|
+
}).catch((err) => log.error('question present failed', err));
|
|
781
|
+
return;
|
|
782
|
+
}
|
|
783
|
+
if (event.type === 'question.replied' || event.type === 'question.rejected') {
|
|
784
|
+
const requestId = props.requestID;
|
|
785
|
+
if (!requestId || !questionFlow)
|
|
786
|
+
return;
|
|
787
|
+
await questionFlow.onExternal(bot.api, requestId, event.type === 'question.replied' ? 'replied' : 'rejected').catch((err) => log.warn('external question cleanup failed', err.message));
|
|
788
|
+
}
|
|
789
|
+
},
|
|
790
|
+
};
|
|
791
|
+
}
|
|
792
|
+
//# sourceMappingURL=index.js.map
|