dw-kit 1.7.0-rc.2 → 1.8.0-rc.2
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/.claude/templates/agent-report.md +35 -35
- package/.dw/config/agents.yml +54 -0
- package/.dw/config/connectors.local.yml +38 -0
- package/.dw/config/connectors.yml +64 -0
- package/.dw/core/AGENTS.md +53 -53
- package/CLAUDE.md +1 -1
- package/bin/dw.mjs +1 -1
- package/package.json +1 -1
- package/src/cli.mjs +170 -0
- package/src/commands/connector.mjs +570 -0
- package/src/commands/session.mjs +401 -0
- package/src/commands/task-watch.mjs +18 -3
- package/src/commands/voice.mjs +1244 -0
- package/src/commands/workspace.mjs +74 -0
- package/src/lib/connector.mjs +150 -0
- package/src/lib/orchestrator.mjs +275 -0
- package/src/lib/session-store.mjs +445 -0
- package/src/lib/session-tree.mjs +127 -0
- package/src/lib/spawn-helpers.mjs +135 -0
- package/src/lib/telegram-bot.mjs +102 -0
- package/src/lib/tls-helpers.mjs +153 -0
- package/src/lib/tts-fallback.mjs +100 -0
- package/src/lib/voice-action.mjs +259 -0
- package/src/lib/voice-log.mjs +105 -0
- package/src/lib/voice-parser.mjs +276 -0
- package/src/lib/workspace-registry.mjs +141 -0
- package/.dw/security/advisory-snapshot.json +0 -157
|
@@ -0,0 +1,570 @@
|
|
|
1
|
+
// connector.mjs — `dw connector telegram start|check` CLI.
|
|
2
|
+
//
|
|
3
|
+
// Phase 2 of G-rgoal-realtime-orch: chat connector. Telegram first because
|
|
4
|
+
// the Bot API is the simplest of the candidates (zero OAuth, no native client,
|
|
5
|
+
// long-poll over HTTPS only). Zalo / Slack land next; they share connector.mjs
|
|
6
|
+
// formatters + parser + session-store access.
|
|
7
|
+
//
|
|
8
|
+
// Per docs/dw_remote_agent_first_context_strategy.md §3.1 (clisbot pattern)
|
|
9
|
+
// and §9 (Zalo / chat channel strategy — security requirements applied here).
|
|
10
|
+
|
|
11
|
+
import { readFileSync, existsSync, mkdirSync, writeFileSync, appendFileSync, chmodSync } from 'node:fs';
|
|
12
|
+
import { join } from 'node:path';
|
|
13
|
+
import yaml from 'js-yaml';
|
|
14
|
+
import chalk from 'chalk';
|
|
15
|
+
import enquirer from 'enquirer';
|
|
16
|
+
const { prompt } = enquirer;
|
|
17
|
+
import { createTelegramClient, isValidTelegramToken, tokenSafeId } from '../lib/telegram-bot.mjs';
|
|
18
|
+
import {
|
|
19
|
+
normalizeIncoming,
|
|
20
|
+
parseCommand,
|
|
21
|
+
splitFirstToken,
|
|
22
|
+
formatStatus,
|
|
23
|
+
formatSessionsList,
|
|
24
|
+
formatSessionShow,
|
|
25
|
+
formatLogs,
|
|
26
|
+
formatHelp,
|
|
27
|
+
formatError,
|
|
28
|
+
formatUnauthorized,
|
|
29
|
+
formatStarted,
|
|
30
|
+
} from '../lib/connector.mjs';
|
|
31
|
+
import {
|
|
32
|
+
createSession,
|
|
33
|
+
listSessions,
|
|
34
|
+
getSession,
|
|
35
|
+
readEvents,
|
|
36
|
+
readOutput,
|
|
37
|
+
updateSessionStatus,
|
|
38
|
+
appendEvent,
|
|
39
|
+
reconcileStaleSessions,
|
|
40
|
+
pruneSessions,
|
|
41
|
+
isValidSessionId,
|
|
42
|
+
openOutputFd,
|
|
43
|
+
closeFd,
|
|
44
|
+
GOAL_MAX_LEN,
|
|
45
|
+
} from '../lib/session-store.mjs';
|
|
46
|
+
import { spawn } from 'node:child_process';
|
|
47
|
+
import { resolve as resolvePath } from 'node:path';
|
|
48
|
+
import { resolveCommandPath, spawnAgent } from '../lib/spawn-helpers.mjs';
|
|
49
|
+
|
|
50
|
+
const CONNECTORS_CONFIG_PATH = '.dw/config/connectors.yml';
|
|
51
|
+
const CONNECTORS_LOCAL_PATH = '.dw/config/connectors.local.yml'; // gitignored; secrets live here
|
|
52
|
+
const AGENTS_CONFIG_PATH = '.dw/config/agents.yml';
|
|
53
|
+
const STATE_DIR = '.dw/cache/connectors/telegram';
|
|
54
|
+
|
|
55
|
+
/** Deep-merge with local taking precedence. Arrays are REPLACED, not concat'd. */
|
|
56
|
+
function deepMerge(base, overlay) {
|
|
57
|
+
if (overlay === undefined || overlay === null) return base;
|
|
58
|
+
if (typeof base !== 'object' || typeof overlay !== 'object' || Array.isArray(base) || Array.isArray(overlay)) {
|
|
59
|
+
return overlay;
|
|
60
|
+
}
|
|
61
|
+
const out = { ...base };
|
|
62
|
+
for (const k of Object.keys(overlay)) {
|
|
63
|
+
out[k] = (k in base) ? deepMerge(base[k], overlay[k]) : overlay[k];
|
|
64
|
+
}
|
|
65
|
+
return out;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function loadConnectorsConfig(rootDir = process.cwd()) {
|
|
69
|
+
const base = join(rootDir, CONNECTORS_CONFIG_PATH);
|
|
70
|
+
const local = join(rootDir, CONNECTORS_LOCAL_PATH);
|
|
71
|
+
const hasBase = existsSync(base);
|
|
72
|
+
const hasLocal = existsSync(local);
|
|
73
|
+
if (!hasBase && !hasLocal) {
|
|
74
|
+
throw new Error(
|
|
75
|
+
`No connectors config found.\n` +
|
|
76
|
+
`Run: dw connector telegram setup\n` +
|
|
77
|
+
`(or copy ${CONNECTORS_CONFIG_PATH} from the dw-kit source.)`
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
let doc = hasBase ? (yaml.load(readFileSync(base, 'utf8')) || {}) : {};
|
|
81
|
+
if (hasLocal) {
|
|
82
|
+
const localDoc = yaml.load(readFileSync(local, 'utf8')) || {};
|
|
83
|
+
doc = deepMerge(doc, localDoc);
|
|
84
|
+
}
|
|
85
|
+
return doc;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function writeLocalConfig(rootDir, patch) {
|
|
89
|
+
const local = join(rootDir, CONNECTORS_LOCAL_PATH);
|
|
90
|
+
const dir = join(rootDir, '.dw', 'config');
|
|
91
|
+
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
|
92
|
+
const existing = existsSync(local) ? (yaml.load(readFileSync(local, 'utf8')) || {}) : {};
|
|
93
|
+
const merged = deepMerge(existing, patch);
|
|
94
|
+
// Add a header comment so end-users understand the file's purpose.
|
|
95
|
+
const header = [
|
|
96
|
+
'# .dw/config/connectors.local.yml — GITIGNORED. Secrets live here.',
|
|
97
|
+
'# Created/updated by `dw connector telegram setup`.',
|
|
98
|
+
'# Token + allow-list overrides the template in .dw/config/connectors.yml.',
|
|
99
|
+
'',
|
|
100
|
+
].join('\n');
|
|
101
|
+
writeFileSync(local, header + yaml.dump(merged), 'utf8');
|
|
102
|
+
// Owner-only perms on POSIX; no-op on Win32.
|
|
103
|
+
if (process.platform !== 'win32') {
|
|
104
|
+
try { chmodSync(local, 0o600); } catch { /* harmless */ }
|
|
105
|
+
}
|
|
106
|
+
return local;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// Defense-in-depth: deep-clone and replace any secret-shaped fields with
|
|
110
|
+
// `<REDACTED>`. Use this BEFORE printing/serializing any config blob. Even
|
|
111
|
+
// the wizard's pretty-print should go through this if it ever shows the full
|
|
112
|
+
// shape — leak-prevention belt-and-suspenders for F-12.
|
|
113
|
+
//
|
|
114
|
+
// Field-name match list is intentionally broad: bot_token, api_key, token,
|
|
115
|
+
// secret, password, credentials, authorization. Sub-objects recursed; arrays
|
|
116
|
+
// scanned. Token-shaped string values (e.g. Telegram tokens that escape into
|
|
117
|
+
// other fields) also get a value-shape redaction pass.
|
|
118
|
+
const SECRET_FIELD_PATTERN = /\b(bot_token|api_key|api_secret|token|secret|password|credentials?|authorization|private_key|access_key)\b/i;
|
|
119
|
+
const TELEGRAM_TOKEN_VALUE_PATTERN = /\d{6,}:[A-Za-z0-9_-]{30,}/g;
|
|
120
|
+
|
|
121
|
+
export function redactConfig(obj) {
|
|
122
|
+
if (obj === null || obj === undefined) return obj;
|
|
123
|
+
if (typeof obj === 'string') {
|
|
124
|
+
// Even at string-level, scrub anything that looks like a Telegram token.
|
|
125
|
+
return obj.replace(TELEGRAM_TOKEN_VALUE_PATTERN, '<REDACTED>');
|
|
126
|
+
}
|
|
127
|
+
if (Array.isArray(obj)) return obj.map(redactConfig);
|
|
128
|
+
if (typeof obj !== 'object') return obj;
|
|
129
|
+
const out = {};
|
|
130
|
+
for (const [k, v] of Object.entries(obj)) {
|
|
131
|
+
if (SECRET_FIELD_PATTERN.test(k)) {
|
|
132
|
+
out[k] = (v === '' || v === null || v === undefined) ? v : '<REDACTED>';
|
|
133
|
+
} else {
|
|
134
|
+
out[k] = redactConfig(v);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
return out;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// Exported for tests + reuse.
|
|
141
|
+
export { loadConnectorsConfig, writeLocalConfig, deepMerge, CONNECTORS_LOCAL_PATH };
|
|
142
|
+
|
|
143
|
+
function loadAgentsConfig(rootDir) {
|
|
144
|
+
const p = join(rootDir, AGENTS_CONFIG_PATH);
|
|
145
|
+
if (!existsSync(p)) return { agents: {} };
|
|
146
|
+
return yaml.load(readFileSync(p, 'utf8')) || { agents: {} };
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function loadOffset(rootDir) {
|
|
150
|
+
const p = join(rootDir, STATE_DIR, 'offset.json');
|
|
151
|
+
if (!existsSync(p)) return 0;
|
|
152
|
+
try { return JSON.parse(readFileSync(p, 'utf8')).offset || 0; }
|
|
153
|
+
catch { return 0; }
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function saveOffset(rootDir, offset) {
|
|
157
|
+
const dir = join(rootDir, STATE_DIR);
|
|
158
|
+
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
|
159
|
+
writeFileSync(join(dir, 'offset.json'), JSON.stringify({ offset, ts: new Date().toISOString() }) + '\n', 'utf8');
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// ─ Authorization ─
|
|
163
|
+
|
|
164
|
+
function isAuthorized(cfg, userId) {
|
|
165
|
+
const allowed = cfg?.telegram?.allowed_user_ids || [];
|
|
166
|
+
return allowed.map(String).includes(String(userId));
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// ─ Audit ─
|
|
170
|
+
|
|
171
|
+
function auditLog(rootDir, entry) {
|
|
172
|
+
const p = join(rootDir, '.dw', 'events-global.jsonl');
|
|
173
|
+
const dir = join(rootDir, '.dw');
|
|
174
|
+
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
|
175
|
+
const line = JSON.stringify({ ts: new Date().toISOString(), source: 'connector:telegram', ...entry }) + '\n';
|
|
176
|
+
try { appendFileSync(p, line, 'utf8'); } catch { /* swallow — audit best-effort */ }
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// ─ Command dispatch ─
|
|
180
|
+
|
|
181
|
+
const HELP_COMMANDS = [
|
|
182
|
+
{ name: 'status', description: 'Show DW status snapshot' },
|
|
183
|
+
{ name: 'sessions [running|all]', description: 'List active/recent sessions' },
|
|
184
|
+
{ name: 'show <id>', description: 'Show one session (state + last output)' },
|
|
185
|
+
{ name: 'logs <id> [tail]', description: 'Tail recent output (default 20 lines)' },
|
|
186
|
+
{ name: 'start <agent> <goal>', description: 'Start a new agent session' },
|
|
187
|
+
{ name: 'stop <id>', description: 'Stop a running session' },
|
|
188
|
+
{ name: 'prune [days]', description: 'Remove old terminal sessions (default 7d)' },
|
|
189
|
+
{ name: 'help', description: 'Show this help' },
|
|
190
|
+
];
|
|
191
|
+
|
|
192
|
+
function resolveSessionByShortId(short, rootDir) {
|
|
193
|
+
// Empty short would match every session via endsWith('') — explicit guard
|
|
194
|
+
// (F-11 from G-dogfood-v1.7 telegram smoke session 2026-05-24).
|
|
195
|
+
if (!short || typeof short !== 'string') return null;
|
|
196
|
+
if (isValidSessionId(short)) return short;
|
|
197
|
+
// Accept last-N-char match — convenient for mobile typing. Require ≥4 chars
|
|
198
|
+
// to avoid ambiguous matches.
|
|
199
|
+
if (short.length < 4) return null;
|
|
200
|
+
const all = listSessions({}, rootDir);
|
|
201
|
+
const match = all.find((s) => s.session_id.endsWith(short));
|
|
202
|
+
return match ? match.session_id : null;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
async function handleCommand({ cmd, incoming, cfg, rootDir }) {
|
|
206
|
+
const name = cmd.name;
|
|
207
|
+
const rest = cmd.rest;
|
|
208
|
+
|
|
209
|
+
if (name === 'status') {
|
|
210
|
+
reconcileStaleSessions(rootDir);
|
|
211
|
+
return formatStatus({ sessions: listSessions({}, rootDir) });
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
if (name === 'sessions') {
|
|
215
|
+
reconcileStaleSessions(rootDir);
|
|
216
|
+
const status = rest === 'running' ? 'running' : 'all';
|
|
217
|
+
const sessions = listSessions({ status }, rootDir);
|
|
218
|
+
return formatSessionsList(sessions);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
if (name === 'show') {
|
|
222
|
+
const id = resolveSessionByShortId(rest, rootDir);
|
|
223
|
+
if (!id) return formatError(`Session not found: ${rest}`);
|
|
224
|
+
const state = getSession(id, rootDir);
|
|
225
|
+
return formatSessionShow({
|
|
226
|
+
state,
|
|
227
|
+
lastEvents: readEvents(id, { tail: 5 }, rootDir),
|
|
228
|
+
lastOutput: readOutput(id, { tail: 10 }, rootDir),
|
|
229
|
+
});
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
if (name === 'logs') {
|
|
233
|
+
const { first, remainder } = splitFirstToken(rest);
|
|
234
|
+
const id = resolveSessionByShortId(first, rootDir);
|
|
235
|
+
if (!id) return formatError(`Session not found: ${first}`);
|
|
236
|
+
const tail = Number(remainder) || 20;
|
|
237
|
+
return formatLogs(readOutput(id, { tail }, rootDir), { tail });
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
if (name === 'start') {
|
|
241
|
+
const agentsCfg = loadAgentsConfig(rootDir);
|
|
242
|
+
let { first: agentName, remainder: goal } = splitFirstToken(rest);
|
|
243
|
+
if (!agentName) agentName = cfg?.telegram?.default_agent || 'claude';
|
|
244
|
+
if (!goal) return formatError('Usage: /start <agent> <goal text>');
|
|
245
|
+
if (goal.length > GOAL_MAX_LEN) return formatError(`Goal too long (${goal.length} > ${GOAL_MAX_LEN})`);
|
|
246
|
+
if (!agentsCfg.agents[agentName]) return formatError(`Unknown agent: ${agentName}`);
|
|
247
|
+
const def = agentsCfg.agents[agentName];
|
|
248
|
+
const args = [...(def.args || [])];
|
|
249
|
+
if ((def.goal_mode || 'trailing-arg') === 'trailing-arg') args.push(goal);
|
|
250
|
+
const workspace = cfg?.telegram?.default_workspace ? resolvePath(cfg.telegram.default_workspace) : rootDir;
|
|
251
|
+
const state = createSession({ agent: agentName, goal, workspacePath: workspace, command: [def.command, ...args] }, rootDir);
|
|
252
|
+
const fd = openOutputFd(state.session_id, rootDir);
|
|
253
|
+
try {
|
|
254
|
+
// F-18 Win32 PATH+PATHEXT resolution.
|
|
255
|
+
const resolved = resolveCommandPath(def.command);
|
|
256
|
+
if (!resolved.found && process.platform === 'win32') {
|
|
257
|
+
closeFd(fd);
|
|
258
|
+
updateSessionStatus(state.session_id, { status: 'failed' }, rootDir);
|
|
259
|
+
return formatError(`Agent CLI not found on PATH: ${def.command}. Install it and re-open the bot terminal.`);
|
|
260
|
+
}
|
|
261
|
+
const child = spawnAgent(resolved, args, {
|
|
262
|
+
cwd: workspace, detached: true, stdio: ['ignore', fd, fd], windowsHide: true,
|
|
263
|
+
});
|
|
264
|
+
updateSessionStatus(state.session_id, { pid: child.pid, status: 'running' }, rootDir);
|
|
265
|
+
appendEvent(state.session_id, { event: 'started', pid: child.pid, command: def.command, args, workspace, via: 'telegram' }, rootDir);
|
|
266
|
+
child.unref();
|
|
267
|
+
closeFd(fd);
|
|
268
|
+
return formatStarted({ sessionId: state.session_id, agent: agentName, goal });
|
|
269
|
+
} catch (e) {
|
|
270
|
+
closeFd(fd);
|
|
271
|
+
updateSessionStatus(state.session_id, { status: 'failed' }, rootDir);
|
|
272
|
+
return formatError(`Failed to spawn ${agentName}: ${e.message}`);
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
if (name === 'stop') {
|
|
277
|
+
const id = resolveSessionByShortId(rest, rootDir);
|
|
278
|
+
if (!id) return formatError(`Session not found: ${rest}`);
|
|
279
|
+
const s = getSession(id, rootDir);
|
|
280
|
+
if (!s) return formatError('Session not found.');
|
|
281
|
+
if (!s.pid) { updateSessionStatus(id, { status: 'exited' }, rootDir); return `_Already exited:_ ${id}`; }
|
|
282
|
+
try {
|
|
283
|
+
process.kill(s.pid, 'SIGTERM');
|
|
284
|
+
updateSessionStatus(id, { status: 'stopped' }, rootDir);
|
|
285
|
+
appendEvent(id, { event: 'stopped', signal: 'SIGTERM', by: `telegram:${incoming.userId}` }, rootDir);
|
|
286
|
+
return `*Stopped* \`${id}\``;
|
|
287
|
+
} catch (e) {
|
|
288
|
+
return formatError(`Cannot stop ${id}: ${e.message}`);
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
if (name === 'prune') {
|
|
293
|
+
const days = Number(rest) || 7;
|
|
294
|
+
const { removed, kept } = pruneSessions({ olderThanMs: days * 86400000 }, rootDir);
|
|
295
|
+
return `_Pruned ${removed.length} session(s); ${kept} kept._`;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
if (name === 'help') {
|
|
299
|
+
return formatHelp(HELP_COMMANDS);
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
return formatError(`Unknown command: /${name}. Try /help.`);
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
// ─ Subcommand: telegram start (long-poll loop) ─
|
|
306
|
+
|
|
307
|
+
export async function connectorTelegramStartCommand(opts) {
|
|
308
|
+
const rootDir = process.cwd();
|
|
309
|
+
let cfg;
|
|
310
|
+
try { cfg = loadConnectorsConfig(rootDir); }
|
|
311
|
+
catch (e) { console.error(chalk.red(`✗ ${e.message}`)); process.exit(1); }
|
|
312
|
+
|
|
313
|
+
if (!cfg.telegram || cfg.telegram.enabled !== true) {
|
|
314
|
+
console.error(chalk.red('✗ Telegram connector disabled in .dw/config/connectors.yml (set `telegram.enabled: true`).'));
|
|
315
|
+
process.exit(1);
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
const token = opts.token || process.env.DW_TG_BOT_TOKEN || cfg.telegram.bot_token;
|
|
319
|
+
if (!isValidTelegramToken(token)) {
|
|
320
|
+
console.error(chalk.red('✗ Invalid or missing Telegram bot token. Set DW_TG_BOT_TOKEN env, --token, or telegram.bot_token in connectors.yml.'));
|
|
321
|
+
process.exit(1);
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
if (!Array.isArray(cfg.telegram.allowed_user_ids) || cfg.telegram.allowed_user_ids.length === 0) {
|
|
325
|
+
console.error(chalk.yellow('⚠ telegram.allowed_user_ids is empty — bot will reject every message. Add your Telegram numeric user id to enable.'));
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
const client = createTelegramClient({ token, baseUrl: opts.baseUrl });
|
|
329
|
+
let me;
|
|
330
|
+
try { me = await client.getMe(); }
|
|
331
|
+
catch (e) { console.error(chalk.red(`✗ getMe failed: ${e.message}`)); process.exit(1); }
|
|
332
|
+
|
|
333
|
+
console.log(chalk.green(` ✓ Telegram connector started`));
|
|
334
|
+
console.log(chalk.dim(` bot: @${me.username} (id ${me.id}, token ${tokenSafeId(token)})`));
|
|
335
|
+
console.log(chalk.dim(` allowed users: ${(cfg.telegram.allowed_user_ids || []).length}`));
|
|
336
|
+
console.log(chalk.dim(` long-poll timeout: ${cfg.telegram.long_poll_timeout_sec || 25}s`));
|
|
337
|
+
if (opts.once) console.log(chalk.dim(` --once: single getUpdates poll then exit`));
|
|
338
|
+
console.log();
|
|
339
|
+
|
|
340
|
+
auditLog(rootDir, { event: 'connector_started', bot_username: me.username, bot_id: me.id });
|
|
341
|
+
|
|
342
|
+
let offset = loadOffset(rootDir);
|
|
343
|
+
let running = true;
|
|
344
|
+
process.on('SIGTERM', () => { running = false; });
|
|
345
|
+
process.on('SIGINT', () => { running = false; });
|
|
346
|
+
|
|
347
|
+
const timeoutSec = Number(cfg.telegram.long_poll_timeout_sec) || 25;
|
|
348
|
+
let pollCount = 0;
|
|
349
|
+
|
|
350
|
+
while (running) {
|
|
351
|
+
let updates;
|
|
352
|
+
try {
|
|
353
|
+
updates = await client.getUpdates(offset, timeoutSec);
|
|
354
|
+
} catch (e) {
|
|
355
|
+
console.error(chalk.yellow(` · getUpdates error (will retry): ${e.message}`));
|
|
356
|
+
await new Promise((r) => setTimeout(r, 5000));
|
|
357
|
+
continue;
|
|
358
|
+
}
|
|
359
|
+
for (const upd of updates) {
|
|
360
|
+
offset = upd.update_id + 1;
|
|
361
|
+
saveOffset(rootDir, offset);
|
|
362
|
+
try { await handleUpdate(upd, { client, cfg, rootDir }); }
|
|
363
|
+
catch (e) {
|
|
364
|
+
console.error(chalk.yellow(` · handleUpdate error: ${e.message}`));
|
|
365
|
+
auditLog(rootDir, { event: 'handler_error', error: e.message });
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
pollCount++;
|
|
369
|
+
if (opts.once) break;
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
console.log(chalk.dim(` Telegram connector stopping (processed ${pollCount} poll cycles).`));
|
|
373
|
+
auditLog(rootDir, { event: 'connector_stopped', poll_cycles: pollCount });
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
async function handleUpdate(upd, { client, cfg, rootDir }) {
|
|
377
|
+
if (!upd.message || !upd.message.text) return; // only handle text messages
|
|
378
|
+
|
|
379
|
+
const incoming = normalizeIncoming({
|
|
380
|
+
channel: 'telegram',
|
|
381
|
+
userId: upd.message.from?.id,
|
|
382
|
+
chatId: upd.message.chat?.id,
|
|
383
|
+
text: upd.message.text,
|
|
384
|
+
timestamp: new Date((upd.message.date || 0) * 1000).toISOString(),
|
|
385
|
+
});
|
|
386
|
+
|
|
387
|
+
auditLog(rootDir, { event: 'message_received', user_id: incoming.userId, chat_id: incoming.chatId, text_preview: incoming.text.slice(0, 80) });
|
|
388
|
+
|
|
389
|
+
if (!isAuthorized(cfg, incoming.userId)) {
|
|
390
|
+
await client.sendMessage(incoming.chatId, formatUnauthorized(), { parseMode: 'Markdown' });
|
|
391
|
+
auditLog(rootDir, { event: 'rejected_unauthorized', user_id: incoming.userId });
|
|
392
|
+
return;
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
const cmd = parseCommand(incoming.text);
|
|
396
|
+
if (!cmd) {
|
|
397
|
+
await client.sendMessage(incoming.chatId, formatHelp(HELP_COMMANDS), { parseMode: 'Markdown' });
|
|
398
|
+
return;
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
let reply;
|
|
402
|
+
try {
|
|
403
|
+
reply = await handleCommand({ cmd, incoming, cfg, rootDir });
|
|
404
|
+
} catch (e) {
|
|
405
|
+
reply = formatError(e.message);
|
|
406
|
+
auditLog(rootDir, { event: 'command_error', cmd: cmd.name, error: e.message });
|
|
407
|
+
}
|
|
408
|
+
await client.sendMessage(incoming.chatId, reply, { parseMode: 'Markdown' });
|
|
409
|
+
auditLog(rootDir, { event: 'command_handled', cmd: cmd.name, user_id: incoming.userId });
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
// ─ Subcommand: telegram check ─
|
|
413
|
+
|
|
414
|
+
export async function connectorTelegramCheckCommand(opts) {
|
|
415
|
+
const rootDir = process.cwd();
|
|
416
|
+
let cfg;
|
|
417
|
+
try { cfg = loadConnectorsConfig(rootDir); }
|
|
418
|
+
catch (e) { console.error(chalk.red(`✗ ${e.message}`)); process.exit(1); }
|
|
419
|
+
|
|
420
|
+
const token = opts.token || process.env.DW_TG_BOT_TOKEN || cfg.telegram?.bot_token;
|
|
421
|
+
if (!isValidTelegramToken(token)) {
|
|
422
|
+
console.error(chalk.red('✗ Invalid or missing Telegram bot token.'));
|
|
423
|
+
process.exit(1);
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
const client = createTelegramClient({ token, baseUrl: opts.baseUrl });
|
|
427
|
+
try {
|
|
428
|
+
const me = await client.getMe();
|
|
429
|
+
console.log(chalk.green(` ✓ Reached Telegram API`));
|
|
430
|
+
console.log(` bot: @${me.username} (id ${me.id})`);
|
|
431
|
+
console.log(` token id: ${tokenSafeId(token)}`);
|
|
432
|
+
console.log(` allowed users: ${(cfg.telegram?.allowed_user_ids || []).length}`);
|
|
433
|
+
console.log(` enabled: ${cfg.telegram?.enabled ? 'yes' : chalk.yellow('NO — start will refuse to run')}`);
|
|
434
|
+
} catch (e) {
|
|
435
|
+
console.error(chalk.red(`✗ getMe failed: ${e.message}`));
|
|
436
|
+
process.exit(1);
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
// ─ Subcommand: telegram setup (interactive wizard) ─
|
|
441
|
+
|
|
442
|
+
export async function connectorTelegramSetupCommand(opts) {
|
|
443
|
+
const rootDir = process.cwd();
|
|
444
|
+
auditLog(rootDir, { event: 'setup_started' });
|
|
445
|
+
|
|
446
|
+
console.log();
|
|
447
|
+
console.log(chalk.bold(' dw connector telegram — setup wizard'));
|
|
448
|
+
console.log(chalk.dim(' ─────────────────────────────────────────'));
|
|
449
|
+
console.log(chalk.dim(' This wizard creates .dw/config/connectors.local.yml'));
|
|
450
|
+
console.log(chalk.dim(' (gitignored) with your bot token + your Telegram user id.'));
|
|
451
|
+
console.log();
|
|
452
|
+
console.log(chalk.dim(' Step 1: Get a bot token'));
|
|
453
|
+
console.log(chalk.dim(' Open Telegram → @BotFather → /newbot → follow prompts.'));
|
|
454
|
+
console.log(chalk.dim(' BotFather replies with a token like 123456789:AAH...'));
|
|
455
|
+
console.log();
|
|
456
|
+
|
|
457
|
+
// Token prompt — password type masks input.
|
|
458
|
+
let token = opts.token || process.env.DW_TG_BOT_TOKEN;
|
|
459
|
+
if (!token) {
|
|
460
|
+
const ans = await prompt({
|
|
461
|
+
type: 'password',
|
|
462
|
+
name: 'token',
|
|
463
|
+
message: 'Paste your bot token (hidden):',
|
|
464
|
+
});
|
|
465
|
+
token = (ans.token || '').trim();
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
if (!isValidTelegramToken(token)) {
|
|
469
|
+
auditLog(rootDir, { event: 'setup_failed', reason: 'invalid_token_format' });
|
|
470
|
+
console.error(chalk.red(`✗ Invalid token format. Expected: <numeric_id>:<secret>`));
|
|
471
|
+
process.exit(1);
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
// Verify token via getMe.
|
|
475
|
+
const client = createTelegramClient({ token, baseUrl: opts.baseUrl });
|
|
476
|
+
let me;
|
|
477
|
+
try { me = await client.getMe(); }
|
|
478
|
+
catch (e) {
|
|
479
|
+
auditLog(rootDir, { event: 'setup_failed', reason: 'getme_failed', error: e.message });
|
|
480
|
+
console.error(chalk.red(`✗ getMe failed: ${e.message}`));
|
|
481
|
+
process.exit(1);
|
|
482
|
+
}
|
|
483
|
+
auditLog(rootDir, { event: 'setup_token_verified', bot_username: me.username, bot_id: me.id, token_safe_id: tokenSafeId(token) });
|
|
484
|
+
|
|
485
|
+
console.log(chalk.green(` ✓ Token works — bot is @${me.username} (id ${me.id})`));
|
|
486
|
+
console.log();
|
|
487
|
+
console.log(chalk.dim(' Step 2: Discover your Telegram user id'));
|
|
488
|
+
console.log(chalk.dim(` Open Telegram → search @${me.username} → send any message (e.g. /start)`));
|
|
489
|
+
console.log();
|
|
490
|
+
console.log(chalk.dim(' Waiting for a message (timeout 90s)…'));
|
|
491
|
+
|
|
492
|
+
// Poll briefly to capture the first incoming user id.
|
|
493
|
+
const deadline = Date.now() + 90_000;
|
|
494
|
+
let discoveredUserId = null;
|
|
495
|
+
let offset = 0;
|
|
496
|
+
while (Date.now() < deadline && !discoveredUserId) {
|
|
497
|
+
const remaining = Math.max(1, Math.ceil((deadline - Date.now()) / 1000));
|
|
498
|
+
const pollTimeout = Math.min(25, remaining);
|
|
499
|
+
let updates;
|
|
500
|
+
try { updates = await client.getUpdates(offset, pollTimeout); }
|
|
501
|
+
catch (e) {
|
|
502
|
+
console.error(chalk.yellow(` · getUpdates: ${e.message}; retrying…`));
|
|
503
|
+
await new Promise((r) => setTimeout(r, 2000));
|
|
504
|
+
continue;
|
|
505
|
+
}
|
|
506
|
+
for (const upd of updates) {
|
|
507
|
+
offset = upd.update_id + 1;
|
|
508
|
+
if (upd.message && upd.message.from && upd.message.from.id) {
|
|
509
|
+
discoveredUserId = upd.message.from.id;
|
|
510
|
+
const fromName = upd.message.from.username
|
|
511
|
+
? `@${upd.message.from.username}`
|
|
512
|
+
: `${upd.message.from.first_name || ''} ${upd.message.from.last_name || ''}`.trim();
|
|
513
|
+
console.log(chalk.green(` ✓ Got message from ${fromName} (user id ${discoveredUserId})`));
|
|
514
|
+
// Reply to confirm
|
|
515
|
+
try {
|
|
516
|
+
await client.sendMessage(upd.message.chat.id,
|
|
517
|
+
`*Setup complete!* Your user id (\`${discoveredUserId}\`) is now on the allow-list.\n\nTry \`/help\` next.`,
|
|
518
|
+
{ parseMode: 'Markdown' });
|
|
519
|
+
} catch { /* best-effort reply */ }
|
|
520
|
+
break;
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
if (!discoveredUserId) {
|
|
526
|
+
auditLog(rootDir, { event: 'setup_failed', reason: 'no_message_in_90s' });
|
|
527
|
+
console.error(chalk.yellow(` · No message received in 90s.`));
|
|
528
|
+
console.log(chalk.dim(` You can still configure manually by editing ${CONNECTORS_LOCAL_PATH}`));
|
|
529
|
+
console.log(chalk.dim(` and adding your numeric user id (find it via @userinfobot).`));
|
|
530
|
+
process.exit(2);
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
// Write local config.
|
|
534
|
+
const path = writeLocalConfig(rootDir, {
|
|
535
|
+
telegram: {
|
|
536
|
+
enabled: true,
|
|
537
|
+
bot_token: token,
|
|
538
|
+
allowed_user_ids: [discoveredUserId],
|
|
539
|
+
},
|
|
540
|
+
});
|
|
541
|
+
auditLog(rootDir, { event: 'setup_completed', bot_username: me.username, bot_id: me.id, discovered_user_id: discoveredUserId, wrote: CONNECTORS_LOCAL_PATH });
|
|
542
|
+
|
|
543
|
+
console.log();
|
|
544
|
+
console.log(chalk.green(' ✓ Setup complete.'));
|
|
545
|
+
console.log(chalk.dim(` wrote: ${path}`));
|
|
546
|
+
console.log(chalk.dim(` bot: @${me.username}`));
|
|
547
|
+
console.log(chalk.dim(` allow: [${discoveredUserId}]`));
|
|
548
|
+
console.log();
|
|
549
|
+
console.log(chalk.bold(' Next:'));
|
|
550
|
+
console.log(chalk.dim(' dw connector telegram start # leave running; Ctrl+C to stop'));
|
|
551
|
+
console.log();
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
// ─ Subcommand: list ─
|
|
555
|
+
|
|
556
|
+
export async function connectorListCommand() {
|
|
557
|
+
const rootDir = process.cwd();
|
|
558
|
+
let cfg;
|
|
559
|
+
try { cfg = loadConnectorsConfig(rootDir); }
|
|
560
|
+
catch (e) { console.log(chalk.dim(` (no connectors config — copy template from dw-kit repo)`)); return; }
|
|
561
|
+
|
|
562
|
+
console.log();
|
|
563
|
+
console.log(chalk.bold(' Configured connectors'));
|
|
564
|
+
for (const [name, conf] of Object.entries(cfg)) {
|
|
565
|
+
if (name === 'schema_version' || typeof conf !== 'object') continue;
|
|
566
|
+
const status = conf.enabled ? chalk.green('enabled') : chalk.gray('disabled');
|
|
567
|
+
console.log(` ${name} ${status}`);
|
|
568
|
+
}
|
|
569
|
+
console.log();
|
|
570
|
+
}
|