@jsayubi/ccgram 1.0.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/.env.example +19 -0
- package/LICENSE +21 -0
- package/README.md +338 -0
- package/ccgram.service +24 -0
- package/config/channels.json +58 -0
- package/config/default.json +27 -0
- package/config/defaults/config.json +16 -0
- package/config/defaults/i18n.json +32 -0
- package/config/email-template.json +31 -0
- package/config/test-with-subagent.json +16 -0
- package/config/user.json +27 -0
- package/dist/claude-hook-notify.d.ts +7 -0
- package/dist/claude-hook-notify.d.ts.map +1 -0
- package/dist/claude-hook-notify.js +154 -0
- package/dist/claude-hook-notify.js.map +1 -0
- package/dist/claude-remote.d.ts +50 -0
- package/dist/claude-remote.d.ts.map +1 -0
- package/dist/claude-remote.js +927 -0
- package/dist/claude-remote.js.map +1 -0
- package/dist/cli.d.ts +3 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +110 -0
- package/dist/cli.js.map +1 -0
- package/dist/enhanced-hook-notify.d.ts +16 -0
- package/dist/enhanced-hook-notify.d.ts.map +1 -0
- package/dist/enhanced-hook-notify.js +288 -0
- package/dist/enhanced-hook-notify.js.map +1 -0
- package/dist/permission-hook.d.ts +15 -0
- package/dist/permission-hook.d.ts.map +1 -0
- package/dist/permission-hook.js +357 -0
- package/dist/permission-hook.js.map +1 -0
- package/dist/prompt-bridge.d.ts +50 -0
- package/dist/prompt-bridge.d.ts.map +1 -0
- package/dist/prompt-bridge.js +173 -0
- package/dist/prompt-bridge.js.map +1 -0
- package/dist/question-notify.d.ts +16 -0
- package/dist/question-notify.d.ts.map +1 -0
- package/dist/question-notify.js +272 -0
- package/dist/question-notify.js.map +1 -0
- package/dist/setup.d.ts +10 -0
- package/dist/setup.d.ts.map +1 -0
- package/dist/setup.js +649 -0
- package/dist/setup.js.map +1 -0
- package/dist/smart-monitor.d.ts +7 -0
- package/dist/smart-monitor.d.ts.map +1 -0
- package/dist/smart-monitor.js +256 -0
- package/dist/smart-monitor.js.map +1 -0
- package/dist/src/automation/claude-automation.d.ts +45 -0
- package/dist/src/automation/claude-automation.d.ts.map +1 -0
- package/dist/src/automation/claude-automation.js +367 -0
- package/dist/src/automation/claude-automation.js.map +1 -0
- package/dist/src/automation/clipboard-automation.d.ts +35 -0
- package/dist/src/automation/clipboard-automation.d.ts.map +1 -0
- package/dist/src/automation/clipboard-automation.js +242 -0
- package/dist/src/automation/clipboard-automation.js.map +1 -0
- package/dist/src/automation/simple-automation.d.ts +56 -0
- package/dist/src/automation/simple-automation.d.ts.map +1 -0
- package/dist/src/automation/simple-automation.js +283 -0
- package/dist/src/automation/simple-automation.js.map +1 -0
- package/dist/src/channels/base/channel.d.ts +60 -0
- package/dist/src/channels/base/channel.d.ts.map +1 -0
- package/dist/src/channels/base/channel.js +96 -0
- package/dist/src/channels/base/channel.js.map +1 -0
- package/dist/src/channels/email/smtp.d.ts +74 -0
- package/dist/src/channels/email/smtp.d.ts.map +1 -0
- package/dist/src/channels/email/smtp.js +605 -0
- package/dist/src/channels/email/smtp.js.map +1 -0
- package/dist/src/channels/line/line.d.ts +36 -0
- package/dist/src/channels/line/line.d.ts.map +1 -0
- package/dist/src/channels/line/line.js +180 -0
- package/dist/src/channels/line/line.js.map +1 -0
- package/dist/src/channels/line/webhook.d.ts +55 -0
- package/dist/src/channels/line/webhook.d.ts.map +1 -0
- package/dist/src/channels/line/webhook.js +191 -0
- package/dist/src/channels/line/webhook.js.map +1 -0
- package/dist/src/channels/local/desktop.d.ts +30 -0
- package/dist/src/channels/local/desktop.d.ts.map +1 -0
- package/dist/src/channels/local/desktop.js +161 -0
- package/dist/src/channels/local/desktop.js.map +1 -0
- package/dist/src/channels/telegram/telegram.d.ts +43 -0
- package/dist/src/channels/telegram/telegram.d.ts.map +1 -0
- package/dist/src/channels/telegram/telegram.js +223 -0
- package/dist/src/channels/telegram/telegram.js.map +1 -0
- package/dist/src/channels/telegram/webhook.d.ts +75 -0
- package/dist/src/channels/telegram/webhook.d.ts.map +1 -0
- package/dist/src/channels/telegram/webhook.js +278 -0
- package/dist/src/channels/telegram/webhook.js.map +1 -0
- package/dist/src/config-manager.d.ts +16 -0
- package/dist/src/config-manager.d.ts.map +1 -0
- package/dist/src/config-manager.js +152 -0
- package/dist/src/config-manager.js.map +1 -0
- package/dist/src/core/config.d.ts +28 -0
- package/dist/src/core/config.d.ts.map +1 -0
- package/dist/src/core/config.js +248 -0
- package/dist/src/core/config.js.map +1 -0
- package/dist/src/core/logger.d.ts +19 -0
- package/dist/src/core/logger.d.ts.map +1 -0
- package/dist/src/core/logger.js +47 -0
- package/dist/src/core/logger.js.map +1 -0
- package/dist/src/core/notifier.d.ts +45 -0
- package/dist/src/core/notifier.d.ts.map +1 -0
- package/dist/src/core/notifier.js +189 -0
- package/dist/src/core/notifier.js.map +1 -0
- package/dist/src/daemon/taskping-daemon.d.ts +38 -0
- package/dist/src/daemon/taskping-daemon.d.ts.map +1 -0
- package/dist/src/daemon/taskping-daemon.js +306 -0
- package/dist/src/daemon/taskping-daemon.js.map +1 -0
- package/dist/src/relay/claude-command-bridge.d.ts +57 -0
- package/dist/src/relay/claude-command-bridge.d.ts.map +1 -0
- package/dist/src/relay/claude-command-bridge.js +188 -0
- package/dist/src/relay/claude-command-bridge.js.map +1 -0
- package/dist/src/relay/command-relay.d.ts +94 -0
- package/dist/src/relay/command-relay.d.ts.map +1 -0
- package/dist/src/relay/command-relay.js +463 -0
- package/dist/src/relay/command-relay.js.map +1 -0
- package/dist/src/relay/email-listener.d.ts +65 -0
- package/dist/src/relay/email-listener.d.ts.map +1 -0
- package/dist/src/relay/email-listener.js +460 -0
- package/dist/src/relay/email-listener.js.map +1 -0
- package/dist/src/relay/relay-pty.d.ts +21 -0
- package/dist/src/relay/relay-pty.d.ts.map +1 -0
- package/dist/src/relay/relay-pty.js +696 -0
- package/dist/src/relay/relay-pty.js.map +1 -0
- package/dist/src/relay/smart-injector.d.ts +30 -0
- package/dist/src/relay/smart-injector.d.ts.map +1 -0
- package/dist/src/relay/smart-injector.js +233 -0
- package/dist/src/relay/smart-injector.js.map +1 -0
- package/dist/src/relay/tmux-injector.d.ts +46 -0
- package/dist/src/relay/tmux-injector.d.ts.map +1 -0
- package/dist/src/relay/tmux-injector.js +413 -0
- package/dist/src/relay/tmux-injector.js.map +1 -0
- package/dist/src/tools/config-manager.d.ts +33 -0
- package/dist/src/tools/config-manager.d.ts.map +1 -0
- package/dist/src/tools/config-manager.js +448 -0
- package/dist/src/tools/config-manager.js.map +1 -0
- package/dist/src/tools/installer.d.ts +38 -0
- package/dist/src/tools/installer.d.ts.map +1 -0
- package/dist/src/tools/installer.js +222 -0
- package/dist/src/tools/installer.js.map +1 -0
- package/dist/src/types/callbacks.d.ts +29 -0
- package/dist/src/types/callbacks.d.ts.map +1 -0
- package/dist/src/types/callbacks.js +7 -0
- package/dist/src/types/callbacks.js.map +1 -0
- package/dist/src/types/config.d.ts +56 -0
- package/dist/src/types/config.d.ts.map +1 -0
- package/dist/src/types/config.js +6 -0
- package/dist/src/types/config.js.map +1 -0
- package/dist/src/types/hooks.d.ts +47 -0
- package/dist/src/types/hooks.d.ts.map +1 -0
- package/dist/src/types/hooks.js +6 -0
- package/dist/src/types/hooks.js.map +1 -0
- package/dist/src/types/index.d.ts +7 -0
- package/dist/src/types/index.d.ts.map +1 -0
- package/dist/src/types/index.js +23 -0
- package/dist/src/types/index.js.map +1 -0
- package/dist/src/types/ipc.d.ts +43 -0
- package/dist/src/types/ipc.d.ts.map +1 -0
- package/dist/src/types/ipc.js +7 -0
- package/dist/src/types/ipc.js.map +1 -0
- package/dist/src/types/session.d.ts +70 -0
- package/dist/src/types/session.d.ts.map +1 -0
- package/dist/src/types/session.js +9 -0
- package/dist/src/types/session.js.map +1 -0
- package/dist/src/types/telegram.d.ts +58 -0
- package/dist/src/types/telegram.d.ts.map +1 -0
- package/dist/src/types/telegram.js +6 -0
- package/dist/src/types/telegram.js.map +1 -0
- package/dist/src/utils/active-check.d.ts +19 -0
- package/dist/src/utils/active-check.d.ts.map +1 -0
- package/dist/src/utils/active-check.js +41 -0
- package/dist/src/utils/active-check.js.map +1 -0
- package/dist/src/utils/callback-parser.d.ts +21 -0
- package/dist/src/utils/callback-parser.d.ts.map +1 -0
- package/dist/src/utils/callback-parser.js +58 -0
- package/dist/src/utils/callback-parser.js.map +1 -0
- package/dist/src/utils/controller-injector.d.ts +21 -0
- package/dist/src/utils/controller-injector.d.ts.map +1 -0
- package/dist/src/utils/controller-injector.js +108 -0
- package/dist/src/utils/controller-injector.js.map +1 -0
- package/dist/src/utils/conversation-tracker.d.ts +32 -0
- package/dist/src/utils/conversation-tracker.d.ts.map +1 -0
- package/dist/src/utils/conversation-tracker.js +119 -0
- package/dist/src/utils/conversation-tracker.js.map +1 -0
- package/dist/src/utils/http-request.d.ts +25 -0
- package/dist/src/utils/http-request.d.ts.map +1 -0
- package/dist/src/utils/http-request.js +66 -0
- package/dist/src/utils/http-request.js.map +1 -0
- package/dist/src/utils/optional-require.d.ts +13 -0
- package/dist/src/utils/optional-require.d.ts.map +1 -0
- package/dist/src/utils/optional-require.js +37 -0
- package/dist/src/utils/optional-require.js.map +1 -0
- package/dist/src/utils/paths.d.ts +11 -0
- package/dist/src/utils/paths.d.ts.map +1 -0
- package/dist/src/utils/paths.js +28 -0
- package/dist/src/utils/paths.js.map +1 -0
- package/dist/src/utils/pty-session-manager.d.ts +42 -0
- package/dist/src/utils/pty-session-manager.d.ts.map +1 -0
- package/dist/src/utils/pty-session-manager.js +182 -0
- package/dist/src/utils/pty-session-manager.js.map +1 -0
- package/dist/src/utils/subagent-tracker.d.ts +64 -0
- package/dist/src/utils/subagent-tracker.d.ts.map +1 -0
- package/dist/src/utils/subagent-tracker.js +191 -0
- package/dist/src/utils/subagent-tracker.js.map +1 -0
- package/dist/src/utils/tmux-monitor.d.ts +102 -0
- package/dist/src/utils/tmux-monitor.d.ts.map +1 -0
- package/dist/src/utils/tmux-monitor.js +642 -0
- package/dist/src/utils/tmux-monitor.js.map +1 -0
- package/dist/src/utils/trace-capture.d.ts +42 -0
- package/dist/src/utils/trace-capture.d.ts.map +1 -0
- package/dist/src/utils/trace-capture.js +102 -0
- package/dist/src/utils/trace-capture.js.map +1 -0
- package/dist/start-all-webhooks.d.ts +7 -0
- package/dist/start-all-webhooks.d.ts.map +1 -0
- package/dist/start-all-webhooks.js +98 -0
- package/dist/start-all-webhooks.js.map +1 -0
- package/dist/start-line-webhook.d.ts +7 -0
- package/dist/start-line-webhook.d.ts.map +1 -0
- package/dist/start-line-webhook.js +59 -0
- package/dist/start-line-webhook.js.map +1 -0
- package/dist/start-relay-pty.d.ts +7 -0
- package/dist/start-relay-pty.d.ts.map +1 -0
- package/dist/start-relay-pty.js +173 -0
- package/dist/start-relay-pty.js.map +1 -0
- package/dist/start-telegram-webhook.d.ts +7 -0
- package/dist/start-telegram-webhook.d.ts.map +1 -0
- package/dist/start-telegram-webhook.js +80 -0
- package/dist/start-telegram-webhook.js.map +1 -0
- package/dist/user-prompt-hook.d.ts +13 -0
- package/dist/user-prompt-hook.d.ts.map +1 -0
- package/dist/user-prompt-hook.js +45 -0
- package/dist/user-prompt-hook.js.map +1 -0
- package/dist/workspace-router.d.ts +78 -0
- package/dist/workspace-router.d.ts.map +1 -0
- package/dist/workspace-router.js +408 -0
- package/dist/workspace-router.js.map +1 -0
- package/dist/workspace-telegram-bot.d.ts +3 -0
- package/dist/workspace-telegram-bot.d.ts.map +1 -0
- package/dist/workspace-telegram-bot.js +1172 -0
- package/dist/workspace-telegram-bot.js.map +1 -0
- package/package.json +80 -0
- package/src/types/callbacks.ts +39 -0
- package/src/types/config.ts +63 -0
- package/src/types/hooks.ts +50 -0
- package/src/types/index.ts +6 -0
- package/src/types/ipc.ts +55 -0
- package/src/types/session.ts +72 -0
- package/src/types/telegram.ts +66 -0
|
@@ -0,0 +1,1172 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
"use strict";
|
|
3
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
4
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
5
|
+
};
|
|
6
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
7
|
+
// Node.js version check — must run before anything else
|
|
8
|
+
const [major] = process.versions.node.split('.').map(Number);
|
|
9
|
+
if (major < 18) {
|
|
10
|
+
console.error(`CCGram requires Node.js >= 18.0.0 (you have ${process.version}).`);
|
|
11
|
+
console.error('Upgrade: https://nodejs.org/ or use nvm: nvm install 18');
|
|
12
|
+
process.exit(1);
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* Workspace Telegram Bot — long-polling bot for remote Claude Code control.
|
|
16
|
+
*
|
|
17
|
+
* Commands:
|
|
18
|
+
* /<workspace> <command> Route a command to the Claude session in that workspace
|
|
19
|
+
* /sessions List all active sessions with workspace names
|
|
20
|
+
* /cmd <TOKEN> <command> Token-based fallback for direct session access
|
|
21
|
+
* /help Show available commands
|
|
22
|
+
* /status [workspace] Show tmux pane output for a workspace
|
|
23
|
+
* /stop [workspace] Interrupt running prompt (Ctrl+C)
|
|
24
|
+
* /compact [workspace] Compact context in a workspace session
|
|
25
|
+
*/
|
|
26
|
+
const path_1 = __importDefault(require("path"));
|
|
27
|
+
const paths_1 = require("./src/utils/paths");
|
|
28
|
+
require('dotenv').config({ path: path_1.default.join(paths_1.PROJECT_ROOT, '.env'), quiet: true });
|
|
29
|
+
const https_1 = __importDefault(require("https"));
|
|
30
|
+
const http_1 = __importDefault(require("http"));
|
|
31
|
+
const fs_1 = __importDefault(require("fs"));
|
|
32
|
+
const child_process_1 = require("child_process");
|
|
33
|
+
const workspace_router_1 = require("./workspace-router");
|
|
34
|
+
const prompt_bridge_1 = require("./prompt-bridge");
|
|
35
|
+
const callback_parser_1 = require("./src/utils/callback-parser");
|
|
36
|
+
const pty_session_manager_1 = require("./src/utils/pty-session-manager");
|
|
37
|
+
const logger_1 = __importDefault(require("./src/core/logger"));
|
|
38
|
+
const logger = new logger_1.default('bot');
|
|
39
|
+
const INJECTION_MODE = process.env.INJECTION_MODE || 'tmux';
|
|
40
|
+
const TMUX_AVAILABLE = (() => {
|
|
41
|
+
try {
|
|
42
|
+
(0, child_process_1.execSync)('tmux -V', { stdio: 'ignore' });
|
|
43
|
+
return true;
|
|
44
|
+
}
|
|
45
|
+
catch {
|
|
46
|
+
return false;
|
|
47
|
+
}
|
|
48
|
+
})();
|
|
49
|
+
const BOT_TOKEN = process.env.TELEGRAM_BOT_TOKEN;
|
|
50
|
+
const CHAT_ID = process.env.TELEGRAM_CHAT_ID;
|
|
51
|
+
if (!BOT_TOKEN || BOT_TOKEN === 'YOUR_BOT_TOKEN_HERE') {
|
|
52
|
+
logger.error('TELEGRAM_BOT_TOKEN not configured in .env');
|
|
53
|
+
logger.error(' Get your token from @BotFather: https://t.me/BotFather');
|
|
54
|
+
process.exit(1);
|
|
55
|
+
}
|
|
56
|
+
if (!CHAT_ID || CHAT_ID === 'YOUR_CHAT_ID_HERE') {
|
|
57
|
+
logger.error('TELEGRAM_CHAT_ID not configured in .env');
|
|
58
|
+
logger.error(' Get your chat ID from @userinfobot: https://t.me/userinfobot');
|
|
59
|
+
process.exit(1);
|
|
60
|
+
}
|
|
61
|
+
let lastUpdateId = 0;
|
|
62
|
+
let lastPollTime = null; // timestamp of last successful getUpdates call
|
|
63
|
+
const startTime = Date.now();
|
|
64
|
+
const activeTypingIntervals = new Map(); // workspace → intervalId
|
|
65
|
+
// ── Telegram API helpers ────────────────────────────────────────
|
|
66
|
+
function telegramAPI(method, body) {
|
|
67
|
+
return new Promise((resolve, reject) => {
|
|
68
|
+
const payload = JSON.stringify(body);
|
|
69
|
+
const options = {
|
|
70
|
+
hostname: 'api.telegram.org',
|
|
71
|
+
path: `/bot${BOT_TOKEN}/${method}`,
|
|
72
|
+
method: 'POST',
|
|
73
|
+
headers: {
|
|
74
|
+
'Content-Type': 'application/json',
|
|
75
|
+
'Content-Length': Buffer.byteLength(payload),
|
|
76
|
+
},
|
|
77
|
+
timeout: method === 'getUpdates' ? 35000 : 10000,
|
|
78
|
+
};
|
|
79
|
+
const req = https_1.default.request(options, (res) => {
|
|
80
|
+
let data = '';
|
|
81
|
+
res.on('data', (chunk) => { data += chunk; });
|
|
82
|
+
res.on('end', () => {
|
|
83
|
+
try {
|
|
84
|
+
const parsed = JSON.parse(data);
|
|
85
|
+
if (!parsed.ok) {
|
|
86
|
+
reject(new Error(`Telegram API error: ${parsed.description || data}`));
|
|
87
|
+
}
|
|
88
|
+
else {
|
|
89
|
+
resolve(parsed.result);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
catch {
|
|
93
|
+
reject(new Error(`Invalid JSON from Telegram: ${data.slice(0, 200)}`));
|
|
94
|
+
}
|
|
95
|
+
});
|
|
96
|
+
});
|
|
97
|
+
req.on('error', reject);
|
|
98
|
+
req.on('timeout', () => {
|
|
99
|
+
req.destroy();
|
|
100
|
+
reject(new Error('Telegram request timed out'));
|
|
101
|
+
});
|
|
102
|
+
req.write(payload);
|
|
103
|
+
req.end();
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
function sendMessage(text) {
|
|
107
|
+
return telegramAPI('sendMessage', {
|
|
108
|
+
chat_id: CHAT_ID,
|
|
109
|
+
text,
|
|
110
|
+
parse_mode: 'Markdown',
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
function sendHtmlMessage(text) {
|
|
114
|
+
return telegramAPI('sendMessage', {
|
|
115
|
+
chat_id: CHAT_ID,
|
|
116
|
+
text,
|
|
117
|
+
parse_mode: 'HTML',
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
const TYPING_SIGNAL_PATH = path_1.default.join(paths_1.PROJECT_ROOT, 'src/data', 'typing-active');
|
|
121
|
+
function startTypingIndicator() {
|
|
122
|
+
stopTypingIndicator();
|
|
123
|
+
try {
|
|
124
|
+
fs_1.default.writeFileSync(TYPING_SIGNAL_PATH, String(Date.now()));
|
|
125
|
+
}
|
|
126
|
+
catch { }
|
|
127
|
+
const tick = () => {
|
|
128
|
+
if (!fs_1.default.existsSync(TYPING_SIGNAL_PATH)) {
|
|
129
|
+
stopTypingIndicator();
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
telegramAPI('sendChatAction', { chat_id: CHAT_ID, action: 'typing' }).catch(() => { });
|
|
133
|
+
};
|
|
134
|
+
tick();
|
|
135
|
+
const intervalId = setInterval(tick, 4500);
|
|
136
|
+
const timeoutId = setTimeout(() => stopTypingIndicator(), 5 * 60 * 1000);
|
|
137
|
+
activeTypingIntervals.set('_active', { intervalId, timeoutId });
|
|
138
|
+
}
|
|
139
|
+
function stopTypingIndicator() {
|
|
140
|
+
const entry = activeTypingIntervals.get('_active');
|
|
141
|
+
if (entry) {
|
|
142
|
+
clearInterval(entry.intervalId);
|
|
143
|
+
clearTimeout(entry.timeoutId);
|
|
144
|
+
activeTypingIntervals.delete('_active');
|
|
145
|
+
}
|
|
146
|
+
try {
|
|
147
|
+
fs_1.default.unlinkSync(TYPING_SIGNAL_PATH);
|
|
148
|
+
}
|
|
149
|
+
catch { }
|
|
150
|
+
}
|
|
151
|
+
async function registerBotCommands() {
|
|
152
|
+
const commands = [
|
|
153
|
+
{ command: 'new', description: 'Start Claude in a project directory' },
|
|
154
|
+
{ command: 'sessions', description: 'List all active Claude sessions' },
|
|
155
|
+
{ command: 'use', description: 'Set or show default workspace' },
|
|
156
|
+
{ command: 'status', description: 'Show current session output' },
|
|
157
|
+
{ command: 'stop', description: 'Interrupt the running prompt' },
|
|
158
|
+
{ command: 'compact', description: 'Compact context in the current session' },
|
|
159
|
+
{ command: 'help', description: 'Show available commands' },
|
|
160
|
+
];
|
|
161
|
+
try {
|
|
162
|
+
await telegramAPI('setMyCommands', { commands });
|
|
163
|
+
logger.info('Bot commands registered with Telegram');
|
|
164
|
+
}
|
|
165
|
+
catch (err) {
|
|
166
|
+
logger.warn(`Failed to register bot commands: ${err.message}`);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
function answerCallbackQuery(callbackQueryId, text) {
|
|
170
|
+
return telegramAPI('answerCallbackQuery', {
|
|
171
|
+
callback_query_id: callbackQueryId,
|
|
172
|
+
text: text || '',
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
function editMessageText(chatId, messageId, text, replyMarkup) {
|
|
176
|
+
const body = {
|
|
177
|
+
chat_id: chatId,
|
|
178
|
+
message_id: messageId,
|
|
179
|
+
text,
|
|
180
|
+
parse_mode: 'Markdown',
|
|
181
|
+
};
|
|
182
|
+
if (replyMarkup)
|
|
183
|
+
body.reply_markup = replyMarkup;
|
|
184
|
+
return telegramAPI('editMessageText', body);
|
|
185
|
+
}
|
|
186
|
+
// ── Command handlers ────────────────────────────────────────────
|
|
187
|
+
async function handleHelp() {
|
|
188
|
+
const defaultWs = (0, workspace_router_1.getDefaultWorkspace)();
|
|
189
|
+
const msg = [
|
|
190
|
+
'*Claude Remote Control*',
|
|
191
|
+
'',
|
|
192
|
+
'`/<workspace> <command>` — Send command to workspace',
|
|
193
|
+
'`/use <workspace>` — Set default workspace',
|
|
194
|
+
'`/use` — Show current default',
|
|
195
|
+
'`/use clear` — Clear default',
|
|
196
|
+
'`/compact [workspace]` — Compact context in workspace',
|
|
197
|
+
'`/new [project]` — Start Claude in a project (shows recent if no arg)',
|
|
198
|
+
'`/sessions` — List active sessions',
|
|
199
|
+
'`/status [workspace]` — Show tmux output',
|
|
200
|
+
'`/stop [workspace]` — Interrupt running prompt',
|
|
201
|
+
'`/cmd <TOKEN> <command>` — Token-based fallback',
|
|
202
|
+
'`/help` — This message',
|
|
203
|
+
'',
|
|
204
|
+
'_Prefix matching:_ `/ass hello` matches `assistant`',
|
|
205
|
+
'_Reply-to:_ Reply to any notification to route to that workspace',
|
|
206
|
+
defaultWs ? `_Default:_ plain text routes to *${escapeMarkdown(defaultWs)}*` : '_Tip:_ Use `/use <workspace>` to send plain text without a prefix',
|
|
207
|
+
].join('\n');
|
|
208
|
+
await sendMessage(msg);
|
|
209
|
+
}
|
|
210
|
+
async function handleSessions() {
|
|
211
|
+
(0, workspace_router_1.pruneExpired)();
|
|
212
|
+
const sessions = (0, workspace_router_1.listActiveSessions)();
|
|
213
|
+
if (sessions.length === 0) {
|
|
214
|
+
await sendMessage('No active sessions.');
|
|
215
|
+
return;
|
|
216
|
+
}
|
|
217
|
+
const lines = sessions.map((s) => {
|
|
218
|
+
const icon = sessionIcon(s);
|
|
219
|
+
return `${icon} *${escapeMarkdown(s.workspace)}* (${s.age})`;
|
|
220
|
+
});
|
|
221
|
+
let footer = '';
|
|
222
|
+
const defaultWs = (0, workspace_router_1.getDefaultWorkspace)();
|
|
223
|
+
if (defaultWs) {
|
|
224
|
+
footer = `\n\n_Default workspace:_ *${escapeMarkdown(defaultWs)}*`;
|
|
225
|
+
}
|
|
226
|
+
await sendMessage(`*Active Sessions*\n\n${lines.join('\n')}${footer}`);
|
|
227
|
+
}
|
|
228
|
+
async function handleStatus(workspaceArg) {
|
|
229
|
+
let workspace;
|
|
230
|
+
if (workspaceArg) {
|
|
231
|
+
workspace = workspaceArg;
|
|
232
|
+
}
|
|
233
|
+
else {
|
|
234
|
+
const defaultWs = (0, workspace_router_1.getDefaultWorkspace)();
|
|
235
|
+
if (defaultWs) {
|
|
236
|
+
workspace = defaultWs;
|
|
237
|
+
}
|
|
238
|
+
else {
|
|
239
|
+
await sendMessage('Usage: `/status <workspace>` or set a default with `/use`.');
|
|
240
|
+
return;
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
const resolved = (0, workspace_router_1.resolveWorkspace)(workspace);
|
|
244
|
+
if (resolved.type === 'none') {
|
|
245
|
+
await sendMessage(`No active session for *${escapeMarkdown(workspace)}*.`);
|
|
246
|
+
return;
|
|
247
|
+
}
|
|
248
|
+
if (resolved.type === 'ambiguous') {
|
|
249
|
+
const names = resolved.matches.map(m => `\`${escapeMarkdown(m.workspace)}\``).join(', ');
|
|
250
|
+
await sendMessage(`Multiple matches: ${names}. Be more specific.`);
|
|
251
|
+
return;
|
|
252
|
+
}
|
|
253
|
+
const match = resolved.match;
|
|
254
|
+
const resolvedName = resolved.workspace;
|
|
255
|
+
const tmuxName = match.session.tmuxSession;
|
|
256
|
+
try {
|
|
257
|
+
const output = await sessionCaptureOutput(tmuxName);
|
|
258
|
+
// Trim and take last 20 lines to avoid message length limits
|
|
259
|
+
const trimmed = output.trim().split('\n').slice(-20).join('\n');
|
|
260
|
+
const htmlMsg = `<b>${escapeHtml(resolvedName)}</b> session output:\n<pre>${escapeHtml(trimmed)}</pre>`;
|
|
261
|
+
try {
|
|
262
|
+
await sendHtmlMessage(htmlMsg);
|
|
263
|
+
}
|
|
264
|
+
catch {
|
|
265
|
+
// Fallback to plain text if HTML fails
|
|
266
|
+
await telegramAPI('sendMessage', { chat_id: CHAT_ID, text: `${resolvedName} session output:\n${trimmed}` });
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
catch (err) {
|
|
270
|
+
await sendMessage(`Could not read session \`${tmuxName}\`: ${err.message}`);
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
async function handleStop(workspaceArg) {
|
|
274
|
+
let workspace;
|
|
275
|
+
if (workspaceArg) {
|
|
276
|
+
workspace = workspaceArg;
|
|
277
|
+
}
|
|
278
|
+
else {
|
|
279
|
+
const defaultWs = (0, workspace_router_1.getDefaultWorkspace)();
|
|
280
|
+
if (defaultWs) {
|
|
281
|
+
workspace = defaultWs;
|
|
282
|
+
}
|
|
283
|
+
else {
|
|
284
|
+
await sendMessage('Usage: `/stop <workspace>` or set a default with `/use`.');
|
|
285
|
+
return;
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
const resolved = (0, workspace_router_1.resolveWorkspace)(workspace);
|
|
289
|
+
if (resolved.type === 'none') {
|
|
290
|
+
await sendMessage(`No active session for *${escapeMarkdown(workspace)}*.`);
|
|
291
|
+
return;
|
|
292
|
+
}
|
|
293
|
+
if (resolved.type === 'ambiguous') {
|
|
294
|
+
const names = resolved.matches.map(m => `\`${escapeMarkdown(m.workspace)}\``).join(', ');
|
|
295
|
+
await sendMessage(`Multiple matches: ${names}. Be more specific.`);
|
|
296
|
+
return;
|
|
297
|
+
}
|
|
298
|
+
const resolvedName = resolved.workspace;
|
|
299
|
+
const tmuxName = resolved.match.session.tmuxSession;
|
|
300
|
+
if (!await sessionExists(tmuxName)) {
|
|
301
|
+
await sendMessage(`Session \`${tmuxName}\` not found.`);
|
|
302
|
+
return;
|
|
303
|
+
}
|
|
304
|
+
try {
|
|
305
|
+
await sessionInterrupt(tmuxName);
|
|
306
|
+
await sendMessage(`\u26d4 Sent interrupt to *${escapeMarkdown(resolvedName)}*`);
|
|
307
|
+
}
|
|
308
|
+
catch (err) {
|
|
309
|
+
await sendMessage(`\u274c Failed to interrupt: ${err.message}`);
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
async function handleCmd(token, command) {
|
|
313
|
+
const map = (0, workspace_router_1.readSessionMap)();
|
|
314
|
+
const session = map[token];
|
|
315
|
+
if (!session) {
|
|
316
|
+
await sendMessage(`No session found for token \`${token}\`.`);
|
|
317
|
+
return;
|
|
318
|
+
}
|
|
319
|
+
if ((0, workspace_router_1.isExpired)(session)) {
|
|
320
|
+
await sendMessage(`Session \`${token}\` has expired.`);
|
|
321
|
+
return;
|
|
322
|
+
}
|
|
323
|
+
await injectAndRespond(session, command, (0, workspace_router_1.extractWorkspaceName)(session.cwd));
|
|
324
|
+
}
|
|
325
|
+
async function handleWorkspaceCommand(workspace, command) {
|
|
326
|
+
const resolved = (0, workspace_router_1.resolveWorkspace)(workspace);
|
|
327
|
+
if (resolved.type === 'none') {
|
|
328
|
+
await sendMessage(`No active session for *${escapeMarkdown(workspace)}*. Use /sessions to see available workspaces.`);
|
|
329
|
+
return;
|
|
330
|
+
}
|
|
331
|
+
if (resolved.type === 'ambiguous') {
|
|
332
|
+
const names = resolved.matches.map(m => `\`${escapeMarkdown(m.workspace)}\``).join(', ');
|
|
333
|
+
await sendMessage(`Multiple matches: ${names}. Be more specific.`);
|
|
334
|
+
return;
|
|
335
|
+
}
|
|
336
|
+
await injectAndRespond(resolved.match.session, command, resolved.workspace);
|
|
337
|
+
}
|
|
338
|
+
async function handleUse(arg) {
|
|
339
|
+
// /use — show current default
|
|
340
|
+
if (!arg) {
|
|
341
|
+
const current = (0, workspace_router_1.getDefaultWorkspace)();
|
|
342
|
+
if (current) {
|
|
343
|
+
await sendMessage(`Default workspace: *${escapeMarkdown(current)}*\n\nPlain text messages will route here. Use \`/use clear\` to unset.`);
|
|
344
|
+
}
|
|
345
|
+
else {
|
|
346
|
+
await sendMessage('No default workspace set. Use `/use <workspace>` to set one.');
|
|
347
|
+
}
|
|
348
|
+
return;
|
|
349
|
+
}
|
|
350
|
+
// /use clear | /use none — clear default
|
|
351
|
+
if (arg === 'clear' || arg === 'none') {
|
|
352
|
+
(0, workspace_router_1.setDefaultWorkspace)(null);
|
|
353
|
+
await sendMessage('Default workspace cleared.');
|
|
354
|
+
return;
|
|
355
|
+
}
|
|
356
|
+
// /use <workspace> — resolve and set
|
|
357
|
+
const resolved = (0, workspace_router_1.resolveWorkspace)(arg);
|
|
358
|
+
if (resolved.type === 'none') {
|
|
359
|
+
await sendMessage(`No active session for *${escapeMarkdown(arg)}*. Use /sessions to see available workspaces.`);
|
|
360
|
+
return;
|
|
361
|
+
}
|
|
362
|
+
if (resolved.type === 'ambiguous') {
|
|
363
|
+
const names = resolved.matches.map(m => `\`${escapeMarkdown(m.workspace)}\``).join(', ');
|
|
364
|
+
await sendMessage(`Multiple matches: ${names}. Be more specific.`);
|
|
365
|
+
return;
|
|
366
|
+
}
|
|
367
|
+
const fullName = resolved.workspace;
|
|
368
|
+
(0, workspace_router_1.setDefaultWorkspace)(fullName);
|
|
369
|
+
await sendMessage(`Default workspace set to *${escapeMarkdown(fullName)}*. Plain text messages will route here.`);
|
|
370
|
+
}
|
|
371
|
+
async function handleCompact(workspaceArg) {
|
|
372
|
+
let workspace;
|
|
373
|
+
if (workspaceArg) {
|
|
374
|
+
workspace = workspaceArg;
|
|
375
|
+
}
|
|
376
|
+
else {
|
|
377
|
+
const defaultWs = (0, workspace_router_1.getDefaultWorkspace)();
|
|
378
|
+
if (defaultWs) {
|
|
379
|
+
workspace = defaultWs;
|
|
380
|
+
}
|
|
381
|
+
else {
|
|
382
|
+
await sendMessage('Usage: `/compact <workspace>` or set a default with `/use`.');
|
|
383
|
+
return;
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
const resolved = (0, workspace_router_1.resolveWorkspace)(workspace);
|
|
387
|
+
if (resolved.type === 'none') {
|
|
388
|
+
await sendMessage(`No active session for *${escapeMarkdown(workspace)}*. Use /sessions to see available workspaces.`);
|
|
389
|
+
return;
|
|
390
|
+
}
|
|
391
|
+
if (resolved.type === 'ambiguous') {
|
|
392
|
+
const names = resolved.matches.map(m => `\`${escapeMarkdown(m.workspace)}\``).join(', ');
|
|
393
|
+
await sendMessage(`Multiple matches: ${names}. Be more specific.`);
|
|
394
|
+
return;
|
|
395
|
+
}
|
|
396
|
+
const tmuxName = resolved.match.session.tmuxSession;
|
|
397
|
+
// Inject /compact into tmux
|
|
398
|
+
const injected = await injectAndRespond(resolved.match.session, '/compact', resolved.workspace);
|
|
399
|
+
if (!injected)
|
|
400
|
+
return;
|
|
401
|
+
// Two-phase polling to detect compact completion:
|
|
402
|
+
// Phase 1: Wait for "Compacting" to appear (command started processing)
|
|
403
|
+
// Phase 2: Wait for "Compacting" to disappear (command finished)
|
|
404
|
+
let started = false;
|
|
405
|
+
// Phase 1: Wait up to 10s for compact to start
|
|
406
|
+
for (let i = 0; i < 5; i++) {
|
|
407
|
+
await sleep(2000);
|
|
408
|
+
try {
|
|
409
|
+
const output = await sessionCaptureOutput(tmuxName);
|
|
410
|
+
if (output.includes('Compacting')) {
|
|
411
|
+
started = true;
|
|
412
|
+
break;
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
catch {
|
|
416
|
+
break;
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
if (!started) {
|
|
420
|
+
// Command may have finished very quickly or failed to start
|
|
421
|
+
try {
|
|
422
|
+
const output = await sessionCaptureOutput(tmuxName);
|
|
423
|
+
if (output.includes('Compacted')) {
|
|
424
|
+
const lines = output.trim().split('\n').slice(-10).join('\n');
|
|
425
|
+
await sendMessage(`\u2705 *${escapeMarkdown(resolved.workspace)}* compact done:\n\`\`\`\n${lines}\n\`\`\``);
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
catch {
|
|
429
|
+
// ignore
|
|
430
|
+
}
|
|
431
|
+
return;
|
|
432
|
+
}
|
|
433
|
+
// Phase 2: Wait up to 60s for "Compacting" to disappear (compact finished)
|
|
434
|
+
for (let i = 0; i < 30; i++) {
|
|
435
|
+
await sleep(2000);
|
|
436
|
+
try {
|
|
437
|
+
const output = await sessionCaptureOutput(tmuxName);
|
|
438
|
+
if (!output.includes('Compacting')) {
|
|
439
|
+
const lines = output.trim().split('\n').slice(-10).join('\n');
|
|
440
|
+
await sendMessage(`\u2705 *${escapeMarkdown(resolved.workspace)}* compact done:\n\`\`\`\n${lines}\n\`\`\``);
|
|
441
|
+
return;
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
catch {
|
|
445
|
+
break;
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
// Timeout — show current session state
|
|
449
|
+
try {
|
|
450
|
+
const output = await sessionCaptureOutput(tmuxName);
|
|
451
|
+
const trimmed = output.trim().split('\n').slice(-5).join('\n');
|
|
452
|
+
await sendMessage(`\u23f3 *${escapeMarkdown(resolved.workspace)}* compact may still be running:\n\`\`\`\n${trimmed}\n\`\`\``);
|
|
453
|
+
}
|
|
454
|
+
catch {
|
|
455
|
+
// ignore
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
async function handleNew(nameArg) {
|
|
459
|
+
if (!nameArg) {
|
|
460
|
+
const recent = (0, workspace_router_1.getRecentProjects)(10);
|
|
461
|
+
if (recent.length === 0) {
|
|
462
|
+
const home = process.env.HOME;
|
|
463
|
+
const dirs = process.env.PROJECT_DIRS
|
|
464
|
+
? process.env.PROJECT_DIRS.split(',').map(d => d.trim().replace(home, '~')).join(', ')
|
|
465
|
+
: '~/projects, ~/tools';
|
|
466
|
+
await sendMessage(`No project history yet.\n\nUse \`/new <project-name>\` to start.\nSearches: ${dirs}, ~/`);
|
|
467
|
+
return;
|
|
468
|
+
}
|
|
469
|
+
const keyboard = [];
|
|
470
|
+
for (let i = 0; i < recent.length; i += 2) {
|
|
471
|
+
const row = recent.slice(i, i + 2).map(p => ({
|
|
472
|
+
text: p.name,
|
|
473
|
+
callback_data: `new:${p.name}`,
|
|
474
|
+
}));
|
|
475
|
+
keyboard.push(row);
|
|
476
|
+
}
|
|
477
|
+
await telegramAPI('sendMessage', {
|
|
478
|
+
chat_id: CHAT_ID,
|
|
479
|
+
text: '*Start Claude Session*\n\nSelect a project or use `/new <name>`:',
|
|
480
|
+
parse_mode: 'Markdown',
|
|
481
|
+
reply_markup: { inline_keyboard: keyboard },
|
|
482
|
+
});
|
|
483
|
+
return;
|
|
484
|
+
}
|
|
485
|
+
await startProject(nameArg);
|
|
486
|
+
}
|
|
487
|
+
async function startProject(name) {
|
|
488
|
+
const home = process.env.HOME;
|
|
489
|
+
// 1. Find project directory — exact match first
|
|
490
|
+
const configuredDirs = process.env.PROJECT_DIRS
|
|
491
|
+
? process.env.PROJECT_DIRS.split(',').map(d => d.trim()).filter(Boolean)
|
|
492
|
+
: [path_1.default.join(home, 'projects'), path_1.default.join(home, 'tools')];
|
|
493
|
+
const candidates = [
|
|
494
|
+
...configuredDirs.map(d => path_1.default.join(d, name)),
|
|
495
|
+
path_1.default.join(home, name),
|
|
496
|
+
];
|
|
497
|
+
let projectDir = null;
|
|
498
|
+
for (const dir of candidates) {
|
|
499
|
+
try {
|
|
500
|
+
if (fs_1.default.statSync(dir).isDirectory()) {
|
|
501
|
+
projectDir = dir;
|
|
502
|
+
break;
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
catch { }
|
|
506
|
+
}
|
|
507
|
+
// 2. If no exact match, prefix match against configured dirs ONLY
|
|
508
|
+
// (skip ~/ to avoid matching Desktop, Documents, Downloads, Library, etc.)
|
|
509
|
+
if (!projectDir) {
|
|
510
|
+
const searchDirs = configuredDirs;
|
|
511
|
+
const matches = [];
|
|
512
|
+
for (const base of searchDirs) {
|
|
513
|
+
try {
|
|
514
|
+
const entries = fs_1.default.readdirSync(base, { withFileTypes: true });
|
|
515
|
+
for (const e of entries) {
|
|
516
|
+
if (e.isDirectory() && e.name.toLowerCase().startsWith(name.toLowerCase())) {
|
|
517
|
+
matches.push({ name: e.name, path: path_1.default.join(base, e.name) });
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
catch { }
|
|
522
|
+
}
|
|
523
|
+
// Deduplicate by name (prefer ~/projects/ over ~/tools/)
|
|
524
|
+
const unique = [...new Map(matches.map(m => [m.name, m])).values()];
|
|
525
|
+
if (unique.length === 1) {
|
|
526
|
+
projectDir = unique[0].path;
|
|
527
|
+
name = unique[0].name;
|
|
528
|
+
}
|
|
529
|
+
else if (unique.length > 1) {
|
|
530
|
+
// Show matches as inline buttons (max 10)
|
|
531
|
+
const limited = unique.slice(0, 10);
|
|
532
|
+
const keyboard = [];
|
|
533
|
+
for (let i = 0; i < limited.length; i += 2) {
|
|
534
|
+
keyboard.push(limited.slice(i, i + 2).map(m => ({
|
|
535
|
+
text: m.name, callback_data: `new:${m.name}`,
|
|
536
|
+
})));
|
|
537
|
+
}
|
|
538
|
+
await telegramAPI('sendMessage', {
|
|
539
|
+
chat_id: CHAT_ID,
|
|
540
|
+
text: `Multiple matches for *${escapeMarkdown(name)}*:`,
|
|
541
|
+
parse_mode: 'Markdown',
|
|
542
|
+
reply_markup: { inline_keyboard: keyboard },
|
|
543
|
+
});
|
|
544
|
+
return;
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
if (!projectDir) {
|
|
548
|
+
const searchedPaths = configuredDirs.map(d => d.replace(home, '~')).join(', ') + ', ~/';
|
|
549
|
+
await sendMessage(`Project \`${escapeMarkdown(name)}\` not found.\n\nSearched: ${searchedPaths}`);
|
|
550
|
+
return;
|
|
551
|
+
}
|
|
552
|
+
// 3. Sanitize tmux session name (dots, colons, spaces are invalid in tmux)
|
|
553
|
+
const tmuxName = name.replace(/[.:\s]/g, '-');
|
|
554
|
+
// 4. Check existing session (PTY or tmux)
|
|
555
|
+
const alreadyRunning = await sessionExists(tmuxName);
|
|
556
|
+
if (alreadyRunning) {
|
|
557
|
+
(0, workspace_router_1.upsertSession)({ cwd: projectDir, tmuxSession: tmuxName, status: 'waiting', sessionId: null });
|
|
558
|
+
(0, workspace_router_1.recordProjectUsage)(name, projectDir);
|
|
559
|
+
(0, workspace_router_1.setDefaultWorkspace)(name);
|
|
560
|
+
await sendMessage(`Session \`${tmuxName}\` already running.\nSet as default — send messages directly.`);
|
|
561
|
+
return;
|
|
562
|
+
}
|
|
563
|
+
// 5. Create session — PTY or tmux
|
|
564
|
+
const usePty = !TMUX_AVAILABLE || INJECTION_MODE === 'pty';
|
|
565
|
+
if (!usePty) {
|
|
566
|
+
// tmux path (existing behaviour)
|
|
567
|
+
try {
|
|
568
|
+
await tmuxExec(`tmux new-session -d -s "${tmuxName}" -c "${projectDir}"`);
|
|
569
|
+
await sleep(300);
|
|
570
|
+
await tmuxExec(`tmux send-keys -t "${tmuxName}" 'claude' C-m`);
|
|
571
|
+
}
|
|
572
|
+
catch (err) {
|
|
573
|
+
await sendMessage(`Failed to start session: ${err.message}`);
|
|
574
|
+
return;
|
|
575
|
+
}
|
|
576
|
+
(0, workspace_router_1.upsertSession)({ cwd: projectDir, tmuxSession: tmuxName, status: 'starting', sessionId: null, sessionType: 'tmux' });
|
|
577
|
+
(0, workspace_router_1.recordProjectUsage)(name, projectDir);
|
|
578
|
+
(0, workspace_router_1.setDefaultWorkspace)(name);
|
|
579
|
+
const msg = await sendMessage(`Started Claude in *${escapeMarkdown(name)}*\n\n` +
|
|
580
|
+
`*Path:* \`${projectDir}\`\n` +
|
|
581
|
+
`*Session:* \`${tmuxName}\`\n\n` +
|
|
582
|
+
`Default workspace set — send messages directly.`);
|
|
583
|
+
if (msg && msg.message_id) {
|
|
584
|
+
(0, workspace_router_1.trackNotificationMessage)(msg.message_id, name, 'new-session');
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
else if (pty_session_manager_1.ptySessionManager.isAvailable()) {
|
|
588
|
+
// PTY path — spawns 'claude' directly (no separate send-keys step)
|
|
589
|
+
const ok = pty_session_manager_1.ptySessionManager.spawn(tmuxName, projectDir);
|
|
590
|
+
if (!ok) {
|
|
591
|
+
await sendMessage('Failed to spawn PTY session.');
|
|
592
|
+
return;
|
|
593
|
+
}
|
|
594
|
+
(0, workspace_router_1.upsertSession)({ cwd: projectDir, tmuxSession: tmuxName, status: 'starting', sessionId: null, sessionType: 'pty' });
|
|
595
|
+
(0, workspace_router_1.recordProjectUsage)(name, projectDir);
|
|
596
|
+
(0, workspace_router_1.setDefaultWorkspace)(name);
|
|
597
|
+
const msg = await sendMessage(`Started Claude in *${escapeMarkdown(name)}*\n\n` +
|
|
598
|
+
`*Path:* \`${projectDir}\`\n` +
|
|
599
|
+
`*Session:* \`${tmuxName}\`\n\n` +
|
|
600
|
+
`Default workspace set — send messages directly.\n\n` +
|
|
601
|
+
`_Headless PTY mode — full Telegram control. Not attachable from terminal._`);
|
|
602
|
+
if (msg && msg.message_id) {
|
|
603
|
+
(0, workspace_router_1.trackNotificationMessage)(msg.message_id, name, 'new-session');
|
|
604
|
+
}
|
|
605
|
+
}
|
|
606
|
+
else {
|
|
607
|
+
await sendMessage('\u26a0\ufe0f tmux not found and node-pty not installed.\n' +
|
|
608
|
+
'Install tmux or run: `npm install node-pty` in ~/.ccgram/');
|
|
609
|
+
}
|
|
610
|
+
}
|
|
611
|
+
async function injectAndRespond(session, command, workspace) {
|
|
612
|
+
const tmuxName = session.tmuxSession;
|
|
613
|
+
if (!await sessionExists(tmuxName)) {
|
|
614
|
+
await sendMessage(`\u26a0\ufe0f Session not found. Start Claude via /new for full remote control, or use tmux.`);
|
|
615
|
+
return false;
|
|
616
|
+
}
|
|
617
|
+
try {
|
|
618
|
+
if (isPtySession(tmuxName)) {
|
|
619
|
+
// PTY: write raw bytes directly — no shell quoting needed
|
|
620
|
+
pty_session_manager_1.ptySessionManager.write(tmuxName, '\x15'); // Ctrl+U: clear line
|
|
621
|
+
await sleep(150);
|
|
622
|
+
pty_session_manager_1.ptySessionManager.write(tmuxName, command); // raw command text
|
|
623
|
+
await sleep(150);
|
|
624
|
+
pty_session_manager_1.ptySessionManager.write(tmuxName, '\r'); // Enter
|
|
625
|
+
}
|
|
626
|
+
else {
|
|
627
|
+
// tmux: existing shell-escaped path
|
|
628
|
+
const escapedCommand = command.replace(/'/g, "'\"'\"'");
|
|
629
|
+
await tmuxExec(`tmux send-keys -t ${tmuxName} C-u`);
|
|
630
|
+
await sleep(150);
|
|
631
|
+
await tmuxExec(`tmux send-keys -t ${tmuxName} '${escapedCommand}'`);
|
|
632
|
+
await sleep(150);
|
|
633
|
+
await tmuxExec(`tmux send-keys -t ${tmuxName} C-m`);
|
|
634
|
+
}
|
|
635
|
+
startTypingIndicator();
|
|
636
|
+
return true;
|
|
637
|
+
}
|
|
638
|
+
catch (err) {
|
|
639
|
+
await sendMessage(`\u274c Failed: ${err.message}`);
|
|
640
|
+
return false;
|
|
641
|
+
}
|
|
642
|
+
}
|
|
643
|
+
function tmuxExec(cmd) {
|
|
644
|
+
return new Promise((resolve, reject) => {
|
|
645
|
+
(0, child_process_1.exec)(cmd, (err) => {
|
|
646
|
+
if (err)
|
|
647
|
+
reject(err);
|
|
648
|
+
else
|
|
649
|
+
resolve(true);
|
|
650
|
+
});
|
|
651
|
+
});
|
|
652
|
+
}
|
|
653
|
+
// ── PTY / tmux dispatch helpers ──────────────────────────────────
|
|
654
|
+
/** Is this session managed as a live PTY handle by this bot process? */
|
|
655
|
+
function isPtySession(sessionName) {
|
|
656
|
+
return pty_session_manager_1.ptySessionManager.has(sessionName);
|
|
657
|
+
}
|
|
658
|
+
/** Check session exists (PTY handle OR tmux session). */
|
|
659
|
+
async function sessionExists(name) {
|
|
660
|
+
if (pty_session_manager_1.ptySessionManager.has(name))
|
|
661
|
+
return true;
|
|
662
|
+
if (TMUX_AVAILABLE) {
|
|
663
|
+
try {
|
|
664
|
+
await tmuxExec(`tmux has-session -t ${name} 2>/dev/null`);
|
|
665
|
+
return true;
|
|
666
|
+
}
|
|
667
|
+
catch {
|
|
668
|
+
return false;
|
|
669
|
+
}
|
|
670
|
+
}
|
|
671
|
+
return false;
|
|
672
|
+
}
|
|
673
|
+
/**
|
|
674
|
+
* Send a named key (Down, Up, Enter, C-m, C-c, C-u, Space) to a session.
|
|
675
|
+
* For PTY: translates to escape sequence via ptySessionManager.sendKey.
|
|
676
|
+
* For tmux: passes key name directly to tmux send-keys.
|
|
677
|
+
*/
|
|
678
|
+
async function sessionSendKey(name, key) {
|
|
679
|
+
if (isPtySession(name)) {
|
|
680
|
+
pty_session_manager_1.ptySessionManager.sendKey(name, key);
|
|
681
|
+
}
|
|
682
|
+
else {
|
|
683
|
+
await tmuxExec(`tmux send-keys -t ${name} ${key}`);
|
|
684
|
+
}
|
|
685
|
+
await sleep(100);
|
|
686
|
+
}
|
|
687
|
+
/** Capture session output (last 20 lines). */
|
|
688
|
+
async function sessionCaptureOutput(name) {
|
|
689
|
+
if (isPtySession(name))
|
|
690
|
+
return pty_session_manager_1.ptySessionManager.capture(name, 20) ?? '';
|
|
691
|
+
return capturePane(name);
|
|
692
|
+
}
|
|
693
|
+
/** Send Ctrl+C interrupt to a session. */
|
|
694
|
+
async function sessionInterrupt(name) {
|
|
695
|
+
if (isPtySession(name))
|
|
696
|
+
pty_session_manager_1.ptySessionManager.interrupt(name);
|
|
697
|
+
else
|
|
698
|
+
await tmuxExec(`tmux send-keys -t ${name} C-c`);
|
|
699
|
+
}
|
|
700
|
+
/** Icon for /sessions listing based on session type and live status. */
|
|
701
|
+
function sessionIcon(s) {
|
|
702
|
+
if (s.session.sessionType === 'pty') {
|
|
703
|
+
return pty_session_manager_1.ptySessionManager.has(s.session.tmuxSession) ? '\u{1F916}' : '\u{1F4A4}'; // 🤖 or 💤
|
|
704
|
+
}
|
|
705
|
+
return s.session.description?.startsWith('waiting') ? '\u23f3' : '\u2705'; // ⏳ or ✅
|
|
706
|
+
}
|
|
707
|
+
// ── Callback query handler ───────────────────────────────────────
|
|
708
|
+
async function processCallbackQuery(query) {
|
|
709
|
+
const chatId = String(query.message?.chat?.id);
|
|
710
|
+
if (chatId !== String(CHAT_ID)) {
|
|
711
|
+
logger.warn(`Ignoring callback from unauthorized chat: ${chatId}`);
|
|
712
|
+
return;
|
|
713
|
+
}
|
|
714
|
+
const data = query.data || '';
|
|
715
|
+
const messageId = query.message?.message_id;
|
|
716
|
+
const originalText = query.message?.text || '';
|
|
717
|
+
logger.info(`Callback: ${data}`);
|
|
718
|
+
const parsed = (0, callback_parser_1.parseCallbackData)(data);
|
|
719
|
+
if (!parsed) {
|
|
720
|
+
await answerCallbackQuery(query.id, 'Invalid callback');
|
|
721
|
+
return;
|
|
722
|
+
}
|
|
723
|
+
const { type } = parsed;
|
|
724
|
+
// Handle new: callback (format: new:<projectName>)
|
|
725
|
+
if (type === 'new') {
|
|
726
|
+
const { projectName } = parsed;
|
|
727
|
+
await answerCallbackQuery(query.id, `Starting ${projectName}...`);
|
|
728
|
+
try {
|
|
729
|
+
await editMessageText(chatId, messageId, `${originalText}\n\n— Starting *${escapeMarkdown(projectName)}*...`);
|
|
730
|
+
}
|
|
731
|
+
catch { }
|
|
732
|
+
await startProject(projectName);
|
|
733
|
+
return;
|
|
734
|
+
}
|
|
735
|
+
const { promptId } = parsed;
|
|
736
|
+
if (type === 'perm') {
|
|
737
|
+
// Permission response: write response file for the polling hook
|
|
738
|
+
const pending = (0, prompt_bridge_1.readPending)(promptId);
|
|
739
|
+
if (!pending) {
|
|
740
|
+
await answerCallbackQuery(query.id, 'Session not found');
|
|
741
|
+
return;
|
|
742
|
+
}
|
|
743
|
+
const { action } = parsed;
|
|
744
|
+
const label = action === 'allow' ? '\u2705 Allowed' : action === 'always' ? '\ud83d\udd13 Always Allowed' : '\u274c Denied';
|
|
745
|
+
// Write response file — the permission-hook.js is polling for this
|
|
746
|
+
try {
|
|
747
|
+
(0, prompt_bridge_1.writeResponse)(promptId, { action });
|
|
748
|
+
logger.info(`Wrote permission response for promptId=${promptId}: action=${action}`);
|
|
749
|
+
await answerCallbackQuery(query.id, label);
|
|
750
|
+
}
|
|
751
|
+
catch (err) {
|
|
752
|
+
logger.error(`Failed to write permission response: ${err.message}`);
|
|
753
|
+
await answerCallbackQuery(query.id, 'Failed to save response');
|
|
754
|
+
return;
|
|
755
|
+
}
|
|
756
|
+
// Edit message to show result and remove buttons
|
|
757
|
+
try {
|
|
758
|
+
await editMessageText(chatId, messageId, `${originalText}\n\n— ${label}`);
|
|
759
|
+
}
|
|
760
|
+
catch (err) {
|
|
761
|
+
logger.error(`Failed to edit message: ${err.message}`);
|
|
762
|
+
}
|
|
763
|
+
}
|
|
764
|
+
else if (type === 'opt') {
|
|
765
|
+
// Question option: inject keystroke via tmux
|
|
766
|
+
const pending = (0, prompt_bridge_1.readPending)(promptId);
|
|
767
|
+
if (!pending || !pending.tmuxSession) {
|
|
768
|
+
await answerCallbackQuery(query.id, 'Session not found');
|
|
769
|
+
return;
|
|
770
|
+
}
|
|
771
|
+
const optIdx = parsed.optionIndex - 1;
|
|
772
|
+
const optionLabel = pending.options && pending.options[optIdx]
|
|
773
|
+
? pending.options[optIdx]
|
|
774
|
+
: `Option ${parsed.optionIndex}`;
|
|
775
|
+
// Multi-select: toggle selection state, update buttons, don't submit yet
|
|
776
|
+
if (pending.multiSelect) {
|
|
777
|
+
const selected = pending.selectedOptions || pending.options.map(() => false);
|
|
778
|
+
selected[optIdx] = !selected[optIdx];
|
|
779
|
+
(0, prompt_bridge_1.updatePending)(promptId, { selectedOptions: selected });
|
|
780
|
+
// Rebuild keyboard with updated checkboxes
|
|
781
|
+
const buttons = pending.options.map((label, idx) => ({
|
|
782
|
+
text: `${selected[idx] ? '\u2611' : '\u2610'} ${idx + 1}. ${label}`,
|
|
783
|
+
callback_data: `opt:${promptId}:${idx + 1}`,
|
|
784
|
+
}));
|
|
785
|
+
const keyboard = [];
|
|
786
|
+
for (let i = 0; i < buttons.length; i += 2) {
|
|
787
|
+
keyboard.push(buttons.slice(i, i + 2));
|
|
788
|
+
}
|
|
789
|
+
keyboard.push([{ text: '\u2705 Submit', callback_data: `opt-submit:${promptId}` }]);
|
|
790
|
+
const checkLabel = selected[optIdx] ? '\u2611' : '\u2610';
|
|
791
|
+
await answerCallbackQuery(query.id, `${checkLabel} ${optionLabel}`);
|
|
792
|
+
// Edit message to show updated buttons
|
|
793
|
+
try {
|
|
794
|
+
await editMessageText(chatId, messageId, originalText, { inline_keyboard: keyboard });
|
|
795
|
+
}
|
|
796
|
+
catch (err) {
|
|
797
|
+
logger.error(`Failed to edit message: ${err.message}`);
|
|
798
|
+
}
|
|
799
|
+
return;
|
|
800
|
+
}
|
|
801
|
+
// Single-select: inject arrow keys + Enter into session
|
|
802
|
+
// Claude Code's AskUserQuestion UI: first option pre-highlighted, Down (N-1) times + Enter
|
|
803
|
+
const downPresses = optIdx;
|
|
804
|
+
const tmuxSessOpt = pending.tmuxSession;
|
|
805
|
+
try {
|
|
806
|
+
for (let i = 0; i < downPresses; i++) {
|
|
807
|
+
await sessionSendKey(tmuxSessOpt, 'Down');
|
|
808
|
+
}
|
|
809
|
+
await sessionSendKey(tmuxSessOpt, 'Enter');
|
|
810
|
+
// For multi-question flows: after the last question, send an extra
|
|
811
|
+
// Enter to confirm the preview/submit step
|
|
812
|
+
if (pending.isLast) {
|
|
813
|
+
await sleep(500);
|
|
814
|
+
await sessionSendKey(tmuxSessOpt, 'Enter');
|
|
815
|
+
}
|
|
816
|
+
await answerCallbackQuery(query.id, `Selected: ${optionLabel}`);
|
|
817
|
+
startTypingIndicator(); // ensure Stop hook routes response back to Telegram
|
|
818
|
+
}
|
|
819
|
+
catch (err) {
|
|
820
|
+
logger.error(`Failed to inject keystroke: ${err.message}`);
|
|
821
|
+
await answerCallbackQuery(query.id, 'Failed to send selection');
|
|
822
|
+
return;
|
|
823
|
+
}
|
|
824
|
+
// Edit message to show selection and remove buttons
|
|
825
|
+
try {
|
|
826
|
+
await editMessageText(chatId, messageId, `${originalText}\n\n— Selected: *${escapeMarkdown(optionLabel)}*`);
|
|
827
|
+
}
|
|
828
|
+
catch (err) {
|
|
829
|
+
logger.error(`Failed to edit message: ${err.message}`);
|
|
830
|
+
}
|
|
831
|
+
(0, prompt_bridge_1.cleanPrompt)(promptId);
|
|
832
|
+
}
|
|
833
|
+
else if (type === 'opt-submit') {
|
|
834
|
+
// Multi-select submit: inject Space toggles for selected options, then Enter
|
|
835
|
+
const pending = (0, prompt_bridge_1.readPending)(promptId);
|
|
836
|
+
if (!pending || !pending.tmuxSession) {
|
|
837
|
+
await answerCallbackQuery(query.id, 'Session not found');
|
|
838
|
+
return;
|
|
839
|
+
}
|
|
840
|
+
const selected = pending.selectedOptions || [];
|
|
841
|
+
const selectedLabels = pending.options.filter((_, idx) => selected[idx]);
|
|
842
|
+
if (selectedLabels.length === 0) {
|
|
843
|
+
await answerCallbackQuery(query.id, 'No options selected');
|
|
844
|
+
return;
|
|
845
|
+
}
|
|
846
|
+
// Inject keystrokes: iterate each option from top, Space if selected, Down to next
|
|
847
|
+
// Claude Code multi-select UI starts with cursor on first option
|
|
848
|
+
// After the listed options, Claude Code adds an auto-generated "Other" option,
|
|
849
|
+
// then Submit. So we need: options.length Downs + 1 more Down to skip "Other"
|
|
850
|
+
const tmuxSessSubmit = pending.tmuxSession;
|
|
851
|
+
try {
|
|
852
|
+
for (let i = 0; i < pending.options.length; i++) {
|
|
853
|
+
if (selected[i]) {
|
|
854
|
+
await sessionSendKey(tmuxSessSubmit, 'Space');
|
|
855
|
+
}
|
|
856
|
+
await sessionSendKey(tmuxSessSubmit, 'Down');
|
|
857
|
+
}
|
|
858
|
+
// Skip past the auto-added "Other" option to reach Submit
|
|
859
|
+
await sessionSendKey(tmuxSessSubmit, 'Down');
|
|
860
|
+
// Cursor is now on Submit — press Enter
|
|
861
|
+
await sessionSendKey(tmuxSessSubmit, 'Enter');
|
|
862
|
+
// For multi-question flows: extra Enter to confirm
|
|
863
|
+
if (pending.isLast) {
|
|
864
|
+
await sleep(500);
|
|
865
|
+
await sessionSendKey(tmuxSessSubmit, 'Enter');
|
|
866
|
+
}
|
|
867
|
+
await answerCallbackQuery(query.id, `Submitted ${selectedLabels.length} options`);
|
|
868
|
+
startTypingIndicator(); // ensure Stop hook routes response back to Telegram
|
|
869
|
+
}
|
|
870
|
+
catch (err) {
|
|
871
|
+
logger.error(`Failed to inject keystrokes: ${err.message}`);
|
|
872
|
+
await answerCallbackQuery(query.id, 'Failed to send selections');
|
|
873
|
+
return;
|
|
874
|
+
}
|
|
875
|
+
// Edit message to show selections and remove buttons
|
|
876
|
+
const selectionText = selectedLabels.map(l => `\u2022 ${escapeMarkdown(l)}`).join('\n');
|
|
877
|
+
try {
|
|
878
|
+
await editMessageText(chatId, messageId, `${originalText}\n\n— Selected:\n${selectionText}`);
|
|
879
|
+
}
|
|
880
|
+
catch (err) {
|
|
881
|
+
logger.error(`Failed to edit message: ${err.message}`);
|
|
882
|
+
}
|
|
883
|
+
(0, prompt_bridge_1.cleanPrompt)(promptId);
|
|
884
|
+
}
|
|
885
|
+
else if (type === 'qperm') {
|
|
886
|
+
// Combined question+permission: allow permission AND inject answer keystroke
|
|
887
|
+
const optIdx = parsed.optionIndex - 1;
|
|
888
|
+
const pending = (0, prompt_bridge_1.readPending)(promptId);
|
|
889
|
+
if (!pending) {
|
|
890
|
+
await answerCallbackQuery(query.id, 'Session not found');
|
|
891
|
+
return;
|
|
892
|
+
}
|
|
893
|
+
const optionLabel = pending.options && pending.options[optIdx]
|
|
894
|
+
? pending.options[optIdx]
|
|
895
|
+
: `Option ${parsed.optionIndex}`;
|
|
896
|
+
// 1. Write permission response (allow) — unblocks the permission hook
|
|
897
|
+
try {
|
|
898
|
+
(0, prompt_bridge_1.writeResponse)(promptId, { action: 'allow', selectedOption: parsed.optionIndex });
|
|
899
|
+
logger.info(`Wrote qperm response for promptId=${promptId}: option=${parsed.optionIndex}`);
|
|
900
|
+
await answerCallbackQuery(query.id, `Selected: ${optionLabel}`);
|
|
901
|
+
}
|
|
902
|
+
catch (err) {
|
|
903
|
+
logger.error(`Failed to write qperm response: ${err.message}`);
|
|
904
|
+
await answerCallbackQuery(query.id, 'Failed to save response');
|
|
905
|
+
return;
|
|
906
|
+
}
|
|
907
|
+
// 2. Schedule keystroke injection after a delay (wait for question UI to appear)
|
|
908
|
+
if (pending.tmuxSession) {
|
|
909
|
+
const tmux = pending.tmuxSession;
|
|
910
|
+
const downPresses = optIdx;
|
|
911
|
+
setTimeout(async () => {
|
|
912
|
+
try {
|
|
913
|
+
for (let i = 0; i < downPresses; i++) {
|
|
914
|
+
await sessionSendKey(tmux, 'Down');
|
|
915
|
+
}
|
|
916
|
+
await sessionSendKey(tmux, 'Enter');
|
|
917
|
+
startTypingIndicator(); // ensure Stop hook routes response back to Telegram
|
|
918
|
+
logger.info(`Injected question answer into ${tmux}: option ${parsed.optionIndex}`);
|
|
919
|
+
}
|
|
920
|
+
catch (err) {
|
|
921
|
+
logger.error(`Failed to inject question answer: ${err.message}`);
|
|
922
|
+
}
|
|
923
|
+
}, 4000); // 4s delay for permission hook to return + question UI to render
|
|
924
|
+
}
|
|
925
|
+
// Edit message to show selection
|
|
926
|
+
try {
|
|
927
|
+
await editMessageText(chatId, messageId, `${originalText}\n\n— Selected: *${escapeMarkdown(optionLabel)}*`);
|
|
928
|
+
}
|
|
929
|
+
catch (err) {
|
|
930
|
+
logger.error(`Failed to edit message: ${err.message}`);
|
|
931
|
+
}
|
|
932
|
+
}
|
|
933
|
+
}
|
|
934
|
+
// ── Message router ──────────────────────────────────────────────
|
|
935
|
+
async function processMessage(msg) {
|
|
936
|
+
stopTypingIndicator();
|
|
937
|
+
// Only accept messages from the configured chat
|
|
938
|
+
const chatId = String(msg.chat.id);
|
|
939
|
+
if (chatId !== String(CHAT_ID)) {
|
|
940
|
+
logger.warn(`Ignoring message from unauthorized chat: ${chatId}`);
|
|
941
|
+
return;
|
|
942
|
+
}
|
|
943
|
+
const text = (msg.text || '').trim();
|
|
944
|
+
if (!text)
|
|
945
|
+
return;
|
|
946
|
+
logger.info(`Received: ${text}`);
|
|
947
|
+
// /help
|
|
948
|
+
if (text === '/help' || text === '/start') {
|
|
949
|
+
await handleHelp();
|
|
950
|
+
return;
|
|
951
|
+
}
|
|
952
|
+
// /sessions
|
|
953
|
+
if (text === '/sessions') {
|
|
954
|
+
await handleSessions();
|
|
955
|
+
return;
|
|
956
|
+
}
|
|
957
|
+
// /status [workspace]
|
|
958
|
+
const statusMatch = text.match(/^\/status(?:\s+(\S+))?$/);
|
|
959
|
+
if (statusMatch) {
|
|
960
|
+
await handleStatus(statusMatch[1] || null);
|
|
961
|
+
return;
|
|
962
|
+
}
|
|
963
|
+
// /stop [workspace]
|
|
964
|
+
const stopMatch = text.match(/^\/stop(?:\s+(\S+))?$/);
|
|
965
|
+
if (stopMatch) {
|
|
966
|
+
await handleStop(stopMatch[1] || null);
|
|
967
|
+
return;
|
|
968
|
+
}
|
|
969
|
+
// /use [workspace]
|
|
970
|
+
const useMatch = text.match(/^\/use(?:\s+(.*))?$/);
|
|
971
|
+
if (useMatch) {
|
|
972
|
+
await handleUse(useMatch[1] ? useMatch[1].trim() : null);
|
|
973
|
+
return;
|
|
974
|
+
}
|
|
975
|
+
// /compact [workspace]
|
|
976
|
+
const compactMatch = text.match(/^\/compact(?:\s+(\S+))?$/);
|
|
977
|
+
if (compactMatch) {
|
|
978
|
+
await handleCompact(compactMatch[1] || null);
|
|
979
|
+
return;
|
|
980
|
+
}
|
|
981
|
+
// /new [project]
|
|
982
|
+
const newMatch = text.match(/^\/new(?:\s+(.+))?$/);
|
|
983
|
+
if (newMatch) {
|
|
984
|
+
await handleNew(newMatch[1] ? newMatch[1].trim() : null);
|
|
985
|
+
return;
|
|
986
|
+
}
|
|
987
|
+
// /cmd TOKEN command
|
|
988
|
+
const cmdMatch = text.match(/^\/cmd\s+(\S+)\s+(.+)/s);
|
|
989
|
+
if (cmdMatch) {
|
|
990
|
+
await handleCmd(cmdMatch[1], cmdMatch[2]);
|
|
991
|
+
return;
|
|
992
|
+
}
|
|
993
|
+
// /<workspace> command (anything starting with / that isn't a known command)
|
|
994
|
+
const wsMatch = text.match(/^\/(\S+)\s+(.+)/s);
|
|
995
|
+
if (wsMatch) {
|
|
996
|
+
const workspace = wsMatch[1];
|
|
997
|
+
const command = wsMatch[2];
|
|
998
|
+
// Skip Telegram built-in bot commands that start with @
|
|
999
|
+
if (workspace.includes('@'))
|
|
1000
|
+
return;
|
|
1001
|
+
await handleWorkspaceCommand(workspace, command);
|
|
1002
|
+
return;
|
|
1003
|
+
}
|
|
1004
|
+
// If just a slash command with no args, check if it's a workspace (with prefix matching)
|
|
1005
|
+
const bareWs = text.match(/^\/(\S+)$/);
|
|
1006
|
+
if (bareWs) {
|
|
1007
|
+
const resolved = (0, workspace_router_1.resolveWorkspace)(bareWs[1]);
|
|
1008
|
+
if (resolved.type === 'exact' || resolved.type === 'prefix') {
|
|
1009
|
+
await handleStatus(resolved.workspace);
|
|
1010
|
+
}
|
|
1011
|
+
else if (resolved.type === 'ambiguous') {
|
|
1012
|
+
const names = resolved.matches.map(m => `\`${escapeMarkdown(m.workspace)}\``).join(', ');
|
|
1013
|
+
await sendMessage(`Multiple matches: ${names}. Be more specific.`);
|
|
1014
|
+
}
|
|
1015
|
+
else {
|
|
1016
|
+
await sendMessage(`Unknown command: \`${text}\`. Try /help`);
|
|
1017
|
+
}
|
|
1018
|
+
return;
|
|
1019
|
+
}
|
|
1020
|
+
// Plain text — try reply-to routing, then default workspace, then show hint
|
|
1021
|
+
const replyToId = msg.reply_to_message && msg.reply_to_message.message_id;
|
|
1022
|
+
if (replyToId) {
|
|
1023
|
+
const replyWorkspace = (0, workspace_router_1.getWorkspaceForMessage)(replyToId);
|
|
1024
|
+
if (replyWorkspace) {
|
|
1025
|
+
await handleWorkspaceCommand(replyWorkspace, text);
|
|
1026
|
+
return;
|
|
1027
|
+
}
|
|
1028
|
+
}
|
|
1029
|
+
const defaultWs = (0, workspace_router_1.getDefaultWorkspace)();
|
|
1030
|
+
if (defaultWs) {
|
|
1031
|
+
await handleWorkspaceCommand(defaultWs, text);
|
|
1032
|
+
return;
|
|
1033
|
+
}
|
|
1034
|
+
await sendMessage('Use `/help` to see available commands, or `/use <workspace>` to set a default.');
|
|
1035
|
+
}
|
|
1036
|
+
// ── Long polling loop ───────────────────────────────────────────
|
|
1037
|
+
async function poll() {
|
|
1038
|
+
while (true) {
|
|
1039
|
+
try {
|
|
1040
|
+
const updates = await telegramAPI('getUpdates', {
|
|
1041
|
+
offset: lastUpdateId + 1,
|
|
1042
|
+
timeout: 30,
|
|
1043
|
+
allowed_updates: ['message', 'callback_query'],
|
|
1044
|
+
});
|
|
1045
|
+
lastPollTime = Date.now();
|
|
1046
|
+
for (const update of updates) {
|
|
1047
|
+
lastUpdateId = update.update_id;
|
|
1048
|
+
if (update.callback_query) {
|
|
1049
|
+
try {
|
|
1050
|
+
await processCallbackQuery(update.callback_query);
|
|
1051
|
+
}
|
|
1052
|
+
catch (err) {
|
|
1053
|
+
logger.error(`Error processing callback query: ${err.message}`);
|
|
1054
|
+
}
|
|
1055
|
+
}
|
|
1056
|
+
else if (update.message) {
|
|
1057
|
+
try {
|
|
1058
|
+
await processMessage(update.message);
|
|
1059
|
+
}
|
|
1060
|
+
catch (err) {
|
|
1061
|
+
logger.error(`Error processing message: ${err.message}`);
|
|
1062
|
+
}
|
|
1063
|
+
}
|
|
1064
|
+
}
|
|
1065
|
+
}
|
|
1066
|
+
catch (err) {
|
|
1067
|
+
// Network error — back off and retry
|
|
1068
|
+
logger.error(`Polling error: ${err.message}`);
|
|
1069
|
+
await sleep(5000);
|
|
1070
|
+
}
|
|
1071
|
+
}
|
|
1072
|
+
}
|
|
1073
|
+
// ── Helpers ──────────────────────────────────────────────────────
|
|
1074
|
+
function capturePane(tmuxSession) {
|
|
1075
|
+
return new Promise((resolve, reject) => {
|
|
1076
|
+
(0, child_process_1.exec)(`tmux capture-pane -t ${tmuxSession} -p`, (err, stdout) => {
|
|
1077
|
+
if (err)
|
|
1078
|
+
reject(err);
|
|
1079
|
+
else
|
|
1080
|
+
resolve(stdout);
|
|
1081
|
+
});
|
|
1082
|
+
});
|
|
1083
|
+
}
|
|
1084
|
+
function escapeMarkdown(text) {
|
|
1085
|
+
// Telegram Markdown v1 only needs these escaped
|
|
1086
|
+
return text.replace(/([_*`\[])/g, '\\$1');
|
|
1087
|
+
}
|
|
1088
|
+
function escapeHtml(text) {
|
|
1089
|
+
return text.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
1090
|
+
}
|
|
1091
|
+
function sleep(ms) {
|
|
1092
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
1093
|
+
}
|
|
1094
|
+
// ── Health check server ──────────────────────────────────────────
|
|
1095
|
+
function startHealthServer(port) {
|
|
1096
|
+
const server = http_1.default.createServer((req, res) => {
|
|
1097
|
+
if (req.url !== '/health') {
|
|
1098
|
+
res.writeHead(404);
|
|
1099
|
+
res.end('Not found');
|
|
1100
|
+
return;
|
|
1101
|
+
}
|
|
1102
|
+
const now = Date.now();
|
|
1103
|
+
const pollAge = lastPollTime ? now - lastPollTime : null;
|
|
1104
|
+
const stale = pollAge === null || pollAge > 60000;
|
|
1105
|
+
const sessions = (0, workspace_router_1.listActiveSessions)();
|
|
1106
|
+
let pendingCount = 0;
|
|
1107
|
+
try {
|
|
1108
|
+
const files = fs_1.default.readdirSync(prompt_bridge_1.PROMPTS_DIR).filter(f => f.startsWith('pending-'));
|
|
1109
|
+
pendingCount = files.length;
|
|
1110
|
+
}
|
|
1111
|
+
catch { }
|
|
1112
|
+
const body = JSON.stringify({
|
|
1113
|
+
status: stale ? 'unhealthy' : 'ok',
|
|
1114
|
+
uptime: Math.floor((now - startTime) / 1000),
|
|
1115
|
+
lastPollAge: pollAge !== null ? Math.floor(pollAge / 1000) : null,
|
|
1116
|
+
activeSessions: sessions.length,
|
|
1117
|
+
pendingPrompts: pendingCount,
|
|
1118
|
+
}, null, 2);
|
|
1119
|
+
res.writeHead(stale ? 503 : 200, { 'Content-Type': 'application/json' });
|
|
1120
|
+
res.end(body);
|
|
1121
|
+
});
|
|
1122
|
+
server.listen(port, '127.0.0.1', () => {
|
|
1123
|
+
logger.info(`Health endpoint: http://127.0.0.1:${port}/health`);
|
|
1124
|
+
});
|
|
1125
|
+
server.on('error', (err) => {
|
|
1126
|
+
logger.warn(`Health server error: ${err.message}`);
|
|
1127
|
+
});
|
|
1128
|
+
}
|
|
1129
|
+
// ── Startup ─────────────────────────────────────────────────────
|
|
1130
|
+
async function start() {
|
|
1131
|
+
// Ensure data directory exists
|
|
1132
|
+
const dataDir = path_1.default.join(paths_1.PROJECT_ROOT, 'src/data');
|
|
1133
|
+
fs_1.default.mkdirSync(dataDir, { recursive: true });
|
|
1134
|
+
const { version } = require(path_1.default.join(paths_1.PROJECT_ROOT, 'package.json'));
|
|
1135
|
+
logger.info(`CCGram v${version} — Starting Telegram bot (long polling)...`);
|
|
1136
|
+
logger.info(`Chat ID: ${CHAT_ID}`);
|
|
1137
|
+
// Prune expired sessions on startup
|
|
1138
|
+
const pruned = (0, workspace_router_1.pruneExpired)();
|
|
1139
|
+
if (pruned > 0) {
|
|
1140
|
+
logger.info(`Pruned ${pruned} expired sessions`);
|
|
1141
|
+
}
|
|
1142
|
+
// Delete any existing webhook to ensure long polling works
|
|
1143
|
+
try {
|
|
1144
|
+
await telegramAPI('deleteWebhook', {});
|
|
1145
|
+
logger.info('Webhook cleared, using long polling');
|
|
1146
|
+
}
|
|
1147
|
+
catch (err) {
|
|
1148
|
+
logger.warn(`Could not delete webhook: ${err.message}`);
|
|
1149
|
+
}
|
|
1150
|
+
// Register bot commands with Telegram (populates the "/" menu in chat)
|
|
1151
|
+
await registerBotCommands();
|
|
1152
|
+
// Start optional health check server
|
|
1153
|
+
const healthPort = parseInt(process.env.HEALTH_PORT, 10);
|
|
1154
|
+
if (healthPort) {
|
|
1155
|
+
startHealthServer(healthPort);
|
|
1156
|
+
}
|
|
1157
|
+
await poll();
|
|
1158
|
+
}
|
|
1159
|
+
// Graceful shutdown
|
|
1160
|
+
process.on('SIGINT', () => {
|
|
1161
|
+
logger.info('Shutting down...');
|
|
1162
|
+
process.exit(0);
|
|
1163
|
+
});
|
|
1164
|
+
process.on('SIGTERM', () => {
|
|
1165
|
+
logger.info('Shutting down...');
|
|
1166
|
+
process.exit(0);
|
|
1167
|
+
});
|
|
1168
|
+
start().catch((err) => {
|
|
1169
|
+
logger.error(`Fatal: ${err.message}`);
|
|
1170
|
+
process.exit(1);
|
|
1171
|
+
});
|
|
1172
|
+
//# sourceMappingURL=workspace-telegram-bot.js.map
|