@lawrenceliang-btc/atel-sdk 1.2.12 → 1.2.13

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/bin/atel.mjs CHANGED
@@ -67,6 +67,7 @@ import {
67
67
  } from '@lawrenceliang-btc/atel-sdk';
68
68
  import { TunnelManager, HeartbeatManager } from './tunnel-manager.mjs';
69
69
  import { buildAgentCallbackAction, getDirectExecutableActions, normalizeGatewayBind, shouldSkipAgentHook, shouldUseGatewaySession } from './notification-action-helpers.mjs';
70
+ import { parseOrderCancelArgs, preflightOrderCancel } from './order-cancel-helpers.mjs';
70
71
  // ollama-manager removed — SDK does not run local models
71
72
  const initializeOllama = async () => {};
72
73
  const getOllamaStatus = async () => ({ running: false, models: [] });
@@ -74,9 +75,69 @@ import { parseAttachmentFlags, processAttachments } from './atel-attachment-help
74
75
 
75
76
  const ATEL_DIR = resolve(process.env.ATEL_DIR || '.atel');
76
77
  const IDENTITY_FILE = resolve(ATEL_DIR, 'identity.json');
77
- const ATEL_PLATFORM = process.env.ATEL_PLATFORM || process.env.ATEL_API || process.env.ATEL_REGISTRY || 'https://api.atelai.org';
78
- const REGISTRY_URL = process.env.ATEL_REGISTRY || ATEL_PLATFORM || 'https://api.atelai.org';
79
- const ATEL_RELAY = process.env.ATEL_RELAY || 'https://api.atelai.org';
78
+
79
+ function readDefaultPlatformBase() {
80
+ const explicit = process.env.ATEL_PLATFORM || process.env.ATEL_API || process.env.ATEL_REGISTRY || '';
81
+ if (String(explicit).trim()) return String(explicit).trim().replace(/\/+$/, '');
82
+ try {
83
+ const networkFile = resolve(ATEL_DIR, 'network.json');
84
+ if (existsSync(networkFile)) {
85
+ const network = JSON.parse(readFileSync(networkFile, 'utf-8'));
86
+ const candidate = [network?.relayUrl, network?.platformUrl, network?.registryUrl].find((value) => typeof value === 'string' && value.trim());
87
+ if (candidate) return String(candidate).trim().replace(/\/+$/, '');
88
+ }
89
+ } catch {}
90
+ return 'https://api.atelai.org';
91
+ }
92
+
93
+ const DEFAULT_PLATFORM_BASE = readDefaultPlatformBase();
94
+ const ATEL_PLATFORM = process.env.ATEL_PLATFORM || process.env.ATEL_API || process.env.ATEL_REGISTRY || DEFAULT_PLATFORM_BASE;
95
+ const REGISTRY_URL = process.env.ATEL_REGISTRY || ATEL_PLATFORM || DEFAULT_PLATFORM_BASE;
96
+ const ATEL_RELAY = process.env.ATEL_RELAY || DEFAULT_PLATFORM_BASE;
97
+
98
+ function normalizeUrl(value = '') {
99
+ return String(value || '').trim().replace(/\/+$/, '');
100
+ }
101
+
102
+ function getEnvironmentProfile(url = '') {
103
+ const normalized = normalizeUrl(url);
104
+ if (!normalized) return { name: 'unknown', label: 'unknown', url: normalized };
105
+ try {
106
+ const parsed = new URL(normalized);
107
+ const host = String(parsed.hostname || '').toLowerCase();
108
+ if (host === 'api.atelai.org') return { name: 'production', label: 'production', url: normalized };
109
+ if (host === '127.0.0.1' || host === 'localhost') return { name: 'test', label: 'local-test', url: normalized };
110
+ if (host === '43.160.230.129' || host === '43.160.211.180') return { name: 'test', label: 'host-test', url: normalized };
111
+ return { name: 'custom', label: host || 'custom', url: normalized };
112
+ } catch {
113
+ return { name: 'custom', label: normalized, url: normalized };
114
+ }
115
+ }
116
+
117
+ function getCurrentEnvironmentSummary() {
118
+ const platform = getEnvironmentProfile(ATEL_PLATFORM);
119
+ const registry = getEnvironmentProfile(REGISTRY_URL);
120
+ const relay = getEnvironmentProfile(ATEL_RELAY);
121
+ const explicit = Boolean(String(process.env.ATEL_PLATFORM || process.env.ATEL_API || process.env.ATEL_REGISTRY || process.env.ATEL_RELAY || '').trim());
122
+ return {
123
+ profile: platform.name,
124
+ platform,
125
+ registry,
126
+ relay,
127
+ explicit,
128
+ atelDir: ATEL_DIR,
129
+ };
130
+ }
131
+
132
+ function ensureProductionAuthTarget() {
133
+ const env = getCurrentEnvironmentSummary();
134
+ if (env.platform.name === 'production') return env;
135
+ console.error(`Authorization blocked: current ATEL_DIR is targeting ${env.platform.label} (${env.platform.url || 'unknown'}), not production.`);
136
+ console.error(`ATEL_DIR: ${env.atelDir}`);
137
+ console.error('To authorize a code from https://atelai.org/login, run with explicit production endpoints:');
138
+ console.error(' ATEL_PLATFORM=https://api.atelai.org ATEL_REGISTRY=https://api.atelai.org ATEL_RELAY=https://api.atelai.org atel auth <code>');
139
+ process.exit(1);
140
+ }
80
141
  const ATEL_NOTIFY_GATEWAY = process.env.ATEL_NOTIFY_GATEWAY || process.env.OPENCLAW_GATEWAY_URL || '';
81
142
  const ATEL_NOTIFY_TARGET = process.env.ATEL_NOTIFY_TARGET || '';
82
143
  const ATEL_NOTIFY_AUTO_BIND = /^(1|true|yes)$/i.test(String(process.env.ATEL_NOTIFY_AUTO_BIND || ''));
@@ -92,8 +153,13 @@ const PENDING_FILE = resolve(ATEL_DIR, 'pending-tasks.json');
92
153
  const RESULT_PUSH_QUEUE_FILE = resolve(ATEL_DIR, 'pending-result-pushes.json');
93
154
  const NOTIFY_TARGETS_FILE = resolve(ATEL_DIR, 'notify-targets.json');
94
155
  const NOTIFY_ROUTES_FILE = resolve(ATEL_DIR, 'notify-routes.json');
156
+ const NOTIFY_CONSENTS_FILE = resolve(ATEL_DIR, 'notify-consents.json');
95
157
  const TELEGRAM_UPDATES_STATE_FILE = resolve(process.env.HOME || '/root', '.openclaw', 'deploy', 'sdk-telegram-updates.json');
96
158
  const TELEGRAM_BINDINGS_FILE = resolve(process.env.HOME || '/root', '.openclaw', 'deploy', 'sdk-telegram-bindings.json');
159
+ const OPENCLAW_CONFIG_FILE = resolve(process.env.HOME || '/root', '.openclaw', 'openclaw.json');
160
+ const OPENCLAW_TG_OFFSET_FILE = resolve(process.env.HOME || '/root', '.openclaw', 'telegram', 'update-offset-default.json');
161
+ const OPENCLAW_GATEWAY_SYSTEMD_FILE = resolve(process.env.HOME || '/root', '.config', 'systemd', 'user', 'openclaw-gateway.service');
162
+ const OPENCLAW_GATEWAY_SYSTEMD_OVERRIDE_FILE = resolve(process.env.HOME || '/root', '.config', 'systemd', 'user', 'openclaw-gateway.service.d', 'env.conf');
97
163
  const TRADE_TRACK_FILE = resolve(ATEL_DIR, 'tracked-orders.json');
98
164
  const P2P_STATUS_FILE = resolve(ATEL_DIR, 'p2p-task-status.jsonl');
99
165
  const PENDING_AGENT_CALLBACKS_FILE = resolve(ATEL_DIR, 'pending-agent-callbacks.json');
@@ -107,6 +173,47 @@ const DEFAULT_POLICY = { rateLimit: 60, maxPayloadBytes: 1048576, maxConcurrent:
107
173
 
108
174
  function ensureDir() { if (!existsSync(ATEL_DIR)) mkdirSync(ATEL_DIR, { recursive: true }); }
109
175
 
176
+ function isPidAlive(pid) {
177
+ const n = Number(pid || 0);
178
+ if (!Number.isFinite(n) || n <= 0) return false;
179
+ try { process.kill(n, 0); return true; }
180
+ catch { return false; }
181
+ }
182
+
183
+ function getStartInstanceLockFile(port) {
184
+ return resolve(ATEL_DIR, `start-instance-${String(port || '3100')}.lock.json`);
185
+ }
186
+
187
+ function acquireStartInstanceLock(port) {
188
+ ensureDir();
189
+ const file = getStartInstanceLockFile(port);
190
+ let existing = null;
191
+ if (existsSync(file)) {
192
+ try { existing = JSON.parse(readFileSync(file, 'utf-8')); } catch {}
193
+ }
194
+ if (existing?.pid && existing.pid !== process.pid && isPidAlive(existing.pid)) {
195
+ return { ok: false, file, existing };
196
+ }
197
+ const payload = {
198
+ pid: process.pid,
199
+ port: Number(port || 0),
200
+ did: (() => { try { return requireIdentity().did; } catch { return ''; } })(),
201
+ startedAt: new Date().toISOString(),
202
+ };
203
+ writeFileSync(file, JSON.stringify(payload, null, 2));
204
+ return { ok: true, file, existing };
205
+ }
206
+
207
+ function releaseStartInstanceLock(port) {
208
+ const file = getStartInstanceLockFile(port);
209
+ if (!existsSync(file)) return false;
210
+ try {
211
+ const current = JSON.parse(readFileSync(file, 'utf-8'));
212
+ if (Number(current?.pid || 0) !== process.pid) return false;
213
+ } catch {}
214
+ try { unlinkSync(file); return true; } catch { return false; }
215
+ }
216
+
110
217
  function ensureOrderWorkspace(orderId, context = {}) {
111
218
  ensureDir();
112
219
  const safeOrderId = String(orderId || 'unknown').replace(/[^a-zA-Z0-9._-]/g, '_');
@@ -212,6 +319,18 @@ function summarizeAgentOutput(text, maxChars = 300) {
212
319
  return trimmed.substring(0, maxChars);
213
320
  }
214
321
 
322
+ function isKnownInvalidLocalAgentStdout(text) {
323
+ const value = String(text || '').trim();
324
+ if (!value) return false;
325
+ const lowered = value.toLowerCase();
326
+ return lowered.includes('401 该令牌已过期')
327
+ || lowered.includes('token expired')
328
+ || lowered.includes('plugin register() called')
329
+ || lowered.includes('plugin registration complete')
330
+ || lowered.includes('session file locked')
331
+ || lowered.includes('session locked');
332
+ }
333
+
215
334
  // ═══════════════════════════════════════════════════════════════════
216
335
  // Notification Target System — auto-discover gateway, manage targets
217
336
  // ═══════════════════════════════════════════════════════════════════
@@ -226,6 +345,86 @@ function saveNotifyTargets(data) {
226
345
  writeFileSync(NOTIFY_TARGETS_FILE, JSON.stringify(data, null, 2));
227
346
  }
228
347
 
348
+ function markNotifyTargetUsed(targets, deliveredTarget, usedAt) {
349
+ const list = Array.isArray(targets?.targets) ? targets.targets : [];
350
+ const id = String(deliveredTarget?.id || '').trim();
351
+ const channel = String(deliveredTarget?.channel || '').trim();
352
+ const targetValue = String(deliveredTarget?.target || '').trim();
353
+ for (const item of list) {
354
+ if (!item || typeof item !== 'object') continue;
355
+ if (id && String(item.id || '').trim() === id) {
356
+ item.lastUsedAt = usedAt;
357
+ return true;
358
+ }
359
+ if (channel && targetValue && String(item.channel || '').trim() === channel && String(item.target || '').trim() === targetValue) {
360
+ item.lastUsedAt = usedAt;
361
+ return true;
362
+ }
363
+ }
364
+ return false;
365
+ }
366
+
367
+ function loadNotifyConsents() {
368
+ try { return JSON.parse(readFileSync(NOTIFY_CONSENTS_FILE, 'utf-8')); }
369
+ catch { return { version: 1, consents: [] }; }
370
+ }
371
+
372
+ function saveNotifyConsents(data) {
373
+ ensureDir();
374
+ writeFileSync(NOTIFY_CONSENTS_FILE, JSON.stringify(data, null, 2));
375
+ }
376
+
377
+ function extractTelegramBotId(botToken = '') {
378
+ const token = String(botToken || '').trim();
379
+ return token ? (token.split(':', 1)[0] || '') : '';
380
+ }
381
+
382
+ function upsertNotifyConsent({ did, channel, target, botToken, source = 'notify_bind', status = 'active' }) {
383
+ const ownerDid = String(did || '').trim();
384
+ const normalizedChannel = String(channel || '').trim();
385
+ const normalizedTarget = String(target || '').trim();
386
+ if (!ownerDid || !normalizedChannel || !normalizedTarget) return false;
387
+ const data = loadNotifyConsents();
388
+ if (!Array.isArray(data.consents)) data.consents = [];
389
+ const now = new Date().toISOString();
390
+ const botId = normalizedChannel === 'telegram' ? extractTelegramBotId(botToken) : '';
391
+ const existing = data.consents.find((item) => item && item.did === ownerDid && item.channel === normalizedChannel && item.target === normalizedTarget);
392
+ if (existing) {
393
+ existing.status = status;
394
+ existing.updatedAt = now;
395
+ existing.revokedAt = status === 'revoked' ? (existing.revokedAt || now) : null;
396
+ if (status !== 'revoked') existing.consentedAt = existing.consentedAt || now;
397
+ existing.source = source || existing.source || 'notify_bind';
398
+ if (botId) existing.botId = botId;
399
+ } else {
400
+ data.consents.push({
401
+ did: ownerDid,
402
+ channel: normalizedChannel,
403
+ target: normalizedTarget,
404
+ botId: botId || undefined,
405
+ source,
406
+ status,
407
+ consentedAt: status === 'revoked' ? null : now,
408
+ revokedAt: status === 'revoked' ? now : null,
409
+ updatedAt: now,
410
+ });
411
+ }
412
+ saveNotifyConsents(data);
413
+ return true;
414
+ }
415
+
416
+ function revokeNotifyConsent({ did, channel, target, reason = 'notify_remove' }) {
417
+ return upsertNotifyConsent({ did, channel, target, source: reason, status: 'revoked' });
418
+ }
419
+
420
+ function listNotifyConsentsForDid(did) {
421
+ const ownerDid = String(did || '').trim();
422
+ if (!ownerDid) return [];
423
+ const data = loadNotifyConsents();
424
+ const items = Array.isArray(data.consents) ? data.consents : [];
425
+ return items.filter((item) => item && item.did === ownerDid);
426
+ }
427
+
229
428
  function loadNotifyRoutes() {
230
429
  try { return JSON.parse(readFileSync(NOTIFY_ROUTES_FILE, 'utf-8')); }
231
430
  catch { return { version: 1, defaultTelegram: null, orderBindings: {} }; }
@@ -289,24 +488,26 @@ function resolveNotifyTargets(orderId = '') {
289
488
  const primary = getPrimaryTelegramTarget();
290
489
  const preferred = (orderId && routes.orderBindings && routes.orderBindings[orderId]) || routes.defaultTelegram || (primary ? { chatId: String(primary.target), botToken: primary.botToken, updatedAt: primary.lastUsedAt || primary.createdAt || new Date().toISOString() } : null);
291
490
  if (!preferred?.chatId) return { targets, enabled };
292
- let hasTelegram = false;
491
+ const bindingTarget = resolveNotifyBindTarget(preferred.chatId, preferred.botToken || discoverTelegramBot() || '');
492
+ let hasBoundTarget = false;
293
493
  enabled = enabled.map((target) => {
294
- if (target.channel !== 'telegram') return target;
295
- hasTelegram = true;
494
+ if (target.channel !== bindingTarget.channel) return target;
495
+ if (String(target.target) !== String(preferred.chatId)) return target;
496
+ hasBoundTarget = true;
296
497
  return {
297
498
  ...target,
298
499
  target: String(preferred.chatId),
299
- botToken: preferred.botToken || target.botToken,
500
+ botToken: bindingTarget.channel === 'telegram' ? (preferred.botToken || target.botToken) : undefined,
300
501
  routeSource: orderId && routes.orderBindings && routes.orderBindings[orderId] ? 'order' : 'default',
301
502
  };
302
503
  });
303
- if (!hasTelegram) {
504
+ if (!hasBoundTarget) {
304
505
  enabled = [{
305
- id: `tg_${preferred.chatId}`,
306
- channel: 'telegram',
506
+ id: bindingTarget.id,
507
+ channel: bindingTarget.channel,
307
508
  target: String(preferred.chatId),
308
- botToken: preferred.botToken || discoverTelegramBot() || undefined,
309
- label: orderId ? `order:${orderId}` : 'owner',
509
+ botToken: bindingTarget.channel === 'telegram' ? (preferred.botToken || discoverTelegramBot() || undefined) : undefined,
510
+ label: orderId ? 'order:' + orderId : 'owner',
310
511
  enabled: true,
311
512
  createdAt: preferred.updatedAt || new Date().toISOString(),
312
513
  lastUsedAt: null,
@@ -351,45 +552,308 @@ function autoBindNotifications() {
351
552
  if (!chatId) return;
352
553
 
353
554
  const botToken = routes.defaultTelegram?.botToken || discoverTelegramBot();
354
- const id = `tg_${chatId}`;
555
+ const bindingTarget = resolveNotifyBindTarget(chatId, botToken || '');
355
556
  targets.targets.push({
356
- id, channel: 'telegram', target: chatId,
357
- botToken: botToken || undefined,
557
+ id: bindingTarget.id,
558
+ channel: bindingTarget.channel,
559
+ target: String(chatId),
560
+ botToken: bindingTarget.channel === 'telegram' ? (botToken || undefined) : undefined,
358
561
  label: 'owner', enabled: true,
359
562
  createdAt: new Date().toISOString(), lastUsedAt: null,
360
563
  });
361
564
  saveNotifyTargets(targets);
362
565
  rememberTelegramRoute(chatId, botToken || undefined);
363
- console.log(`🔔 Auto-bound TG notifications to chat ${chatId}`);
566
+ try { upsertNotifyConsent({ did: requireIdentity().did, channel: 'telegram', target: String(chatId), botToken, source: 'auto_bind', status: 'active' }); } catch {}
567
+ console.log(`🔔 Auto-bound ${bindingTarget.channel} notifications to chat ${chatId}`);
364
568
  }
365
569
 
366
570
  // Auto-discover OpenClaw gateway: read token + find port
571
+ function loadOpenClawGatewayRuntimeHints() {
572
+ const hints = { ports: [], binds: [], urls: [] };
573
+ const addPort = (value) => {
574
+ const port = Number.parseInt(String(value || '').trim(), 10);
575
+ if (Number.isFinite(port) && port > 0 && !hints.ports.includes(port)) hints.ports.push(port);
576
+ };
577
+ const addBind = (value) => {
578
+ const bind = normalizeGatewayBind(String(value || '').trim() || '127.0.0.1');
579
+ if (bind && !hints.binds.includes(bind)) hints.binds.push(bind);
580
+ };
581
+ const addUrl = (value) => {
582
+ const url = String(value || '').trim();
583
+ if (url && !hints.urls.includes(url)) hints.urls.push(url);
584
+ };
585
+ const parseRuntimeText = (content) => {
586
+ if (!content) return;
587
+ for (const match of content.matchAll(/(?:--port|OPENCLAW_GATEWAY_PORT=)(\d+)/g)) addPort(match[1]);
588
+ for (const match of content.matchAll(/(?:--host|--bind)\s+([^\s"']+)/g)) addBind(match[1]);
589
+ for (const match of content.matchAll(/OPENCLAW_GATEWAY_URL=([^\s"']+)/g)) addUrl(match[1]);
590
+ };
591
+ addPort(process.env.OPENCLAW_GATEWAY_PORT);
592
+ addBind(process.env.OPENCLAW_GATEWAY_BIND || process.env.OPENCLAW_GATEWAY_HOST);
593
+ addUrl(process.env.OPENCLAW_GATEWAY_URL || process.env.ATEL_NOTIFY_GATEWAY);
594
+ for (const candidate of [OPENCLAW_GATEWAY_SYSTEMD_FILE, OPENCLAW_GATEWAY_SYSTEMD_OVERRIDE_FILE]) {
595
+ try {
596
+ if (existsSync(candidate)) parseRuntimeText(readFileSync(candidate, 'utf-8'));
597
+ } catch {}
598
+ }
599
+ return hints;
600
+ }
601
+
367
602
  function discoverGateway() {
368
- // 1. Env override
369
- if (process.env.ATEL_NOTIFY_GATEWAY || process.env.OPENCLAW_GATEWAY_URL) {
370
- const url = process.env.ATEL_NOTIFY_GATEWAY || process.env.OPENCLAW_GATEWAY_URL;
371
- let token = '';
372
- try { token = JSON.parse(readFileSync(join(process.env.HOME || '', '.openclaw/openclaw.json'), 'utf-8')).gateway?.auth?.token || ''; } catch {}
373
- return { url, token };
374
- }
375
- // 2. Read from ~/.openclaw/openclaw.json
603
+ const candidateUrls = [];
604
+ const seen = new Set();
605
+ const addCandidate = (url, source) => {
606
+ const normalized = String(url || '').trim();
607
+ if (!normalized || seen.has(normalized)) return;
608
+ seen.add(normalized);
609
+ candidateUrls.push({ url: normalized, source });
610
+ };
611
+ const addPortCandidates = (port, bind, source) => {
612
+ const parsedPort = Number.parseInt(String(port || '').trim(), 10);
613
+ if (!Number.isFinite(parsedPort) || parsedPort <= 0) return;
614
+ const hosts = new Set([
615
+ normalizeGatewayBind(bind || '127.0.0.1'),
616
+ '127.0.0.1',
617
+ 'localhost',
618
+ ]);
619
+ for (const host of hosts) addCandidate(`http://${host}:${parsedPort}`, source);
620
+ };
621
+
622
+ let token = '';
623
+ let cfg = null;
376
624
  try {
377
- const cfg = JSON.parse(readFileSync(join(process.env.HOME || '', '.openclaw/openclaw.json'), 'utf-8'));
378
- const token = cfg.gateway?.auth?.token || '';
379
- const port = cfg.gateway?.port || 18789;
380
- const bind = normalizeGatewayBind(cfg.gateway?.bind || '127.0.0.1');
381
- return { url: `http://${bind}:${port}`, token };
382
- } catch { return null; }
625
+ cfg = JSON.parse(readFileSync(OPENCLAW_CONFIG_FILE, 'utf-8'));
626
+ token = String(cfg?.gateway?.auth?.token || '').trim();
627
+ } catch {}
628
+
629
+ const explicitUrl = process.env.ATEL_NOTIFY_GATEWAY || process.env.OPENCLAW_GATEWAY_URL;
630
+ if (explicitUrl) addCandidate(explicitUrl, 'env_url');
631
+
632
+ const hints = loadOpenClawGatewayRuntimeHints();
633
+ for (const url of hints.urls) addCandidate(url, 'runtime_url');
634
+ for (const port of hints.ports) addPortCandidates(port, hints.binds[0], 'runtime_port');
635
+
636
+ if (cfg?.gateway?.port) addPortCandidates(cfg.gateway.port, cfg.gateway?.bind || '127.0.0.1', 'config');
637
+
638
+ const fallbackPort = Number.parseInt(String(process.env.OPENCLAW_GATEWAY_PORT || '').trim(), 10);
639
+ if (Number.isFinite(fallbackPort) && fallbackPort > 0) addPortCandidates(fallbackPort, process.env.OPENCLAW_GATEWAY_BIND || process.env.OPENCLAW_GATEWAY_HOST || '127.0.0.1', 'env_port');
640
+
641
+ if (candidateUrls.length === 0) addPortCandidates(18789, '127.0.0.1', 'default');
642
+
643
+ if (!token && candidateUrls.length === 0) return null;
644
+ return { url: candidateUrls[0]?.url || '', token, candidates: candidateUrls };
645
+ }
646
+
647
+ async function invokeGatewayTelegramMessage(target, message, timeoutMs = 5000) {
648
+ const gw = discoverGateway();
649
+ const candidates = Array.isArray(gw?.candidates) && gw.candidates.length > 0 ? gw.candidates : (gw?.url ? [{ url: gw.url, source: 'legacy' }] : []);
650
+ let last = { ok: false, reason: 'missing_gateway', error: 'missing_gateway', status: 0, url: '' };
651
+ if (gw?.token && candidates.length > 0) {
652
+ for (const candidate of candidates) {
653
+ try {
654
+ const resp = await fetch(`${candidate.url}/tools/invoke`, {
655
+ method: 'POST',
656
+ headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${gw.token}` },
657
+ body: JSON.stringify({ tool: 'message', args: { action: 'send', channel: 'telegram', message, target } }),
658
+ signal: AbortSignal.timeout(timeoutMs),
659
+ });
660
+ if (resp.ok) return { ok: true, status: resp.status, url: candidate.url, source: candidate.source };
661
+ let detail = `http_${resp.status}`;
662
+ try {
663
+ const payload = await resp.json();
664
+ detail = payload?.error?.message || payload?.message || detail;
665
+ } catch {}
666
+ last = { ok: false, reason: 'http_error', error: detail, status: resp.status, url: candidate.url, source: candidate.source };
667
+ } catch (e) {
668
+ last = { ok: false, reason: 'fetch_failed', error: e.message || 'fetch_failed', status: 0, url: candidate.url, source: candidate.source };
669
+ }
670
+ }
671
+ }
672
+
673
+ const oc = loadOpenClawConfig();
674
+ const botToken = String(oc?.channels?.telegram?.botToken || '').trim();
675
+ if (botToken) {
676
+ try {
677
+ const data = await sendTelegramBotRequest(botToken, 'sendMessage', { chat_id: target, text: message }, timeoutMs);
678
+ if (data?.ok !== false) {
679
+ return { ok: true, status: 200, url: 'telegram://openclaw-bot', source: 'openclaw_bot_fallback' };
680
+ }
681
+ last = { ok: false, reason: 'telegram_http_error', error: data?.description || 'http_200', status: 200, url: 'telegram://openclaw-bot', source: 'openclaw_bot_fallback' };
682
+ } catch (e) {
683
+ last = { ok: false, reason: 'telegram_fetch_failed', error: e.message || 'fetch_failed', status: 0, url: 'telegram://openclaw-bot', source: 'openclaw_bot_fallback' };
684
+ }
685
+ }
686
+ return last;
687
+ }
688
+
689
+ async function sendTelegramBotRequest(botToken, method, payload, timeoutMs = 5000) {
690
+ const url = `https://api.telegram.org/bot${botToken}/${method}`;
691
+ const options = {
692
+ method: 'POST',
693
+ signal: AbortSignal.timeout(timeoutMs),
694
+ };
695
+ if (payload instanceof FormData) {
696
+ options.body = payload;
697
+ } else {
698
+ options.headers = { 'Content-Type': 'application/json' };
699
+ options.body = JSON.stringify(payload);
700
+ }
701
+ const resp = await fetch(url, options);
702
+ const data = await resp.json().catch(() => null);
703
+ if (!resp.ok || data?.ok === false) {
704
+ throw new Error(data?.description || `http_${resp.status}`);
705
+ }
706
+ return data;
707
+ }
708
+
709
+ function decodeDataUrl(dataUrl = '') {
710
+ const match = String(dataUrl || '').match(/^data:([^;]+);base64,(.+)$/);
711
+ if (!match) return null;
712
+ const [, mimeType, base64] = match;
713
+ try {
714
+ return { mimeType, buffer: Buffer.from(base64, 'base64') };
715
+ } catch {
716
+ return null;
717
+ }
718
+ }
719
+
720
+ function buildMediaCaption(message = '', payload = {}, index = 0) {
721
+ const base = String(message || '').trim();
722
+ const sender = String(payload.peerDid || '').trim();
723
+ const extra = index === 0 && sender ? `来自: ${sender}` : '';
724
+ return [base, extra].filter(Boolean).join('\n').slice(0, 900);
725
+ }
726
+
727
+ async function fetchTelegramUploadSource(item, defaultName, defaultMimeType, timeoutMs = 5000) {
728
+ if (item?.kind === 'inline' && item?.data) {
729
+ const decoded = decodeDataUrl(item.data);
730
+ if (decoded?.buffer) {
731
+ return {
732
+ name: item.name || defaultName,
733
+ mimeType: decoded.mimeType || item.mimeType || defaultMimeType,
734
+ buffer: decoded.buffer,
735
+ };
736
+ }
737
+ }
738
+ const mediaUrl = String(item?.downloadUrl || item?.url || '').trim();
739
+ if (!mediaUrl) throw new Error('missing_media_url');
740
+ const resp = await fetch(mediaUrl, { signal: AbortSignal.timeout(timeoutMs) });
741
+ if (!resp.ok) throw new Error(`attachment_fetch_http_${resp.status}`);
742
+ const arrayBuffer = await resp.arrayBuffer();
743
+ return {
744
+ name: item?.name || defaultName,
745
+ mimeType: item?.mimeType || resp.headers.get('content-type') || defaultMimeType,
746
+ buffer: Buffer.from(arrayBuffer),
747
+ };
748
+ }
749
+
750
+ async function sendTelegramPhoto(botToken, chatId, image, caption = '', timeoutMs = 5000) {
751
+ const source = await fetchTelegramUploadSource(image, 'image', image?.mimeType || 'image/png', timeoutMs);
752
+ const form = new FormData();
753
+ form.append('chat_id', String(chatId));
754
+ if (caption) form.append('caption', caption);
755
+ form.append('photo', new Blob([source.buffer], { type: source.mimeType || 'image/png' }), source.name || 'image');
756
+ return await sendTelegramBotRequest(botToken, 'sendPhoto', form, timeoutMs);
757
+ }
758
+
759
+ async function sendTelegramDocumentLike(botToken, method, fieldName, chatId, attachment, caption = '', timeoutMs = 5000) {
760
+ const source = await fetchTelegramUploadSource(attachment, attachment?.name || fieldName, attachment?.mimeType || 'application/octet-stream', timeoutMs);
761
+ const form = new FormData();
762
+ form.append('chat_id', String(chatId));
763
+ if (caption) form.append('caption', caption);
764
+ form.append(fieldName, new Blob([source.buffer], { type: source.mimeType || 'application/octet-stream' }), source.name || fieldName);
765
+ return await sendTelegramBotRequest(botToken, method, form, timeoutMs);
766
+ }
767
+
768
+ async function sendTelegramRichMedia(botToken, chatId, message, payload = {}, timeoutMs = 5000) {
769
+ const images = Array.isArray(payload.images) ? payload.images : [];
770
+ const attachments = Array.isArray(payload.attachments) ? payload.attachments : [];
771
+ let sent = false;
772
+ for (let i = 0; i < images.length; i++) {
773
+ await sendTelegramPhoto(botToken, chatId, images[i], buildMediaCaption(message, payload, i), timeoutMs);
774
+ sent = true;
775
+ }
776
+ for (let i = 0; i < attachments.length; i++) {
777
+ const attachment = attachments[i] || {};
778
+ const kind = String(attachment.kind || '').toLowerCase();
779
+ const mimeType = String(attachment.mimeType || '').toLowerCase();
780
+ const caption = buildMediaCaption(message, payload, images.length + i);
781
+ if (kind === 'audio' || mimeType.startsWith('audio/')) {
782
+ try {
783
+ await sendTelegramDocumentLike(botToken, 'sendAudio', 'audio', chatId, attachment, caption, timeoutMs);
784
+ } catch {
785
+ await sendTelegramDocumentLike(botToken, 'sendDocument', 'document', chatId, attachment, caption, timeoutMs);
786
+ }
787
+ } else if (kind === 'video' || mimeType.startsWith('video/')) {
788
+ try {
789
+ await sendTelegramDocumentLike(botToken, 'sendVideo', 'video', chatId, attachment, caption, timeoutMs);
790
+ } catch {
791
+ await sendTelegramDocumentLike(botToken, 'sendDocument', 'document', chatId, attachment, caption, timeoutMs);
792
+ }
793
+ } else {
794
+ await sendTelegramDocumentLike(botToken, 'sendDocument', 'document', chatId, attachment, caption, timeoutMs);
795
+ }
796
+ sent = true;
797
+ }
798
+ return sent;
383
799
  }
384
800
 
385
801
  function loadOpenClawConfig() {
386
802
  try {
387
- return JSON.parse(readFileSync(join(process.env.HOME || '', '.openclaw/openclaw.json'), 'utf-8'));
803
+ return JSON.parse(readFileSync(OPENCLAW_CONFIG_FILE, 'utf-8'));
388
804
  } catch {
389
805
  return null;
390
806
  }
391
807
  }
392
808
 
809
+ function loadOpenClawTelegramOffset() {
810
+ try {
811
+ return JSON.parse(readFileSync(OPENCLAW_TG_OFFSET_FILE, 'utf-8'));
812
+ } catch {
813
+ return null;
814
+ }
815
+ }
816
+
817
+ function getTelegramInboundHostMode(config) {
818
+ const explicit = String(process.env.ATEL_NOTIFY_TG_INGRESS || '').trim().toLowerCase();
819
+ if (explicit === 'sdk') return { mode: 'sdk', reason: 'env_override_sdk' };
820
+ if (explicit === 'openclaw') return { mode: 'openclaw', reason: 'env_override_openclaw' };
821
+ const oc = loadOpenClawConfig();
822
+ const enabled = oc?.channels?.telegram?.enabled === true;
823
+ const botToken = String(oc?.channels?.telegram?.botToken || '').trim();
824
+ const offset = loadOpenClawTelegramOffset();
825
+ const offsetBotId = String(offset?.botId || '').trim();
826
+ if (enabled && botToken && config?.botToken && botToken === config.botToken) {
827
+ return { mode: 'openclaw', reason: offsetBotId && offsetBotId === String(config.botId || '') ? 'openclaw_same_bot_active' : 'openclaw_same_bot_configured' };
828
+ }
829
+ return { mode: 'sdk', reason: 'sdk_default' };
830
+ }
831
+
832
+ function resolveNotifyBindTarget(chatId, botToken) {
833
+ const normalizedChatId = String(chatId || '').trim();
834
+ const normalizedBotToken = String(botToken || '').trim();
835
+ const botId = normalizedBotToken ? (normalizedBotToken.split(':', 1)[0] || 'telegram') : 'telegram';
836
+ const ingress = getTelegramInboundHostMode({ chatId: normalizedChatId, botToken: normalizedBotToken, botId });
837
+ if (ingress.mode === 'openclaw') {
838
+ return {
839
+ channel: 'gateway',
840
+ id: 'gw_' + normalizedChatId,
841
+ target: normalizedChatId,
842
+ label: 'owner',
843
+ ingress,
844
+ botToken: undefined,
845
+ };
846
+ }
847
+ return {
848
+ channel: 'telegram',
849
+ id: 'tg_' + normalizedChatId,
850
+ target: normalizedChatId,
851
+ label: 'owner',
852
+ ingress,
853
+ botToken: normalizedBotToken || undefined,
854
+ };
855
+ }
856
+
393
857
  // Read TG bot token from openclaw config
394
858
  function discoverTelegramBot() {
395
859
  try {
@@ -441,6 +905,45 @@ function registerTelegramInboundBinding(did, config) {
441
905
  return { ownerDid: did, rebound: true };
442
906
  }
443
907
 
908
+ function getTelegramBindingKey(chatId, botToken) {
909
+ const normalizedChatId = String(chatId || '').trim();
910
+ const normalizedBotToken = String(botToken || '').trim();
911
+ if (!normalizedChatId || !normalizedBotToken) return null;
912
+ const botId = normalizedBotToken.split(':', 1)[0] || 'telegram';
913
+ return { key: `${botId}:${normalizedChatId}`, botId, chatId: normalizedChatId };
914
+ }
915
+
916
+ function ensureTelegramBindingOwnership(did, chatId, botToken) {
917
+ const binding = getTelegramBindingKey(chatId, botToken);
918
+ if (!did || !binding) return { ok: true, ownerDid: String(did || '').trim(), key: binding?.key || '' };
919
+ const data = loadTelegramBindings();
920
+ if (!data.bindings || typeof data.bindings !== 'object') data.bindings = {};
921
+ const current = data.bindings[binding.key];
922
+ const ownerDid = String(current?.ownerDid || '').trim();
923
+ if (ownerDid && ownerDid !== did) {
924
+ return { ok: false, ownerDid, key: binding.key, botId: binding.botId, chatId: binding.chatId };
925
+ }
926
+ return { ok: true, ownerDid: ownerDid || String(did).trim(), key: binding.key, botId: binding.botId, chatId: binding.chatId };
927
+ }
928
+
929
+ function releaseTelegramBindingOwnership(did, chatId, botToken) {
930
+ const binding = getTelegramBindingKey(chatId, botToken);
931
+ if (!did || !binding) return false;
932
+ const data = loadTelegramBindings();
933
+ if (!data.bindings || typeof data.bindings !== 'object') data.bindings = {};
934
+ const current = data.bindings[binding.key];
935
+ if (!current || String(current.ownerDid || '').trim() !== String(did).trim()) return false;
936
+ delete data.bindings[binding.key];
937
+ saveTelegramBindings(data);
938
+ const state = loadTelegramUpdatesState();
939
+ if (state.leaders && state.leaders[binding.key] && String(state.leaders[binding.key].did || '').trim() === String(did).trim()) {
940
+ delete state.leaders[binding.key];
941
+ saveTelegramUpdatesState(state);
942
+ }
943
+ return true;
944
+ }
945
+
946
+
444
947
  function acquireTelegramInboundLeader(did, config) {
445
948
  const state = loadTelegramUpdatesState();
446
949
  if (!state.leaders || typeof state.leaders !== 'object') state.leaders = {};
@@ -535,6 +1038,11 @@ async function pollTelegramInboundUpdates(targetDid) {
535
1038
  const config = getTelegramInboundConfig();
536
1039
  if (!config) return { status: 'noop' };
537
1040
 
1041
+ const ingressMode = getTelegramInboundHostMode(config);
1042
+ if (ingressMode.mode === 'openclaw') {
1043
+ return { status: 'hosted_by_openclaw', reason: ingressMode.reason, botId: config.botId, chatId: config.chatId };
1044
+ }
1045
+
538
1046
  const binding = registerTelegramInboundBinding(targetDid, config);
539
1047
  if (binding.ownerDid && binding.ownerDid !== targetDid) {
540
1048
  return { status: 'follower', ownerDid: binding.ownerDid };
@@ -652,9 +1160,14 @@ async function pushTradeNotification(eventType, payload, body) {
652
1160
  'milestone_rejected': (p) => {
653
1161
  const desc = p.milestoneDescription ? `
654
1162
  目标: ${p.milestoneDescription}` : '';
1163
+ const submitCount = Number(p.submitCount || 0);
1164
+ const arbitrationNote = submitCount >= 3 || p.maxReached
1165
+ ? `
1166
+ 由于连续 3 次被拒,已进入仲裁待决状态,等待人工决定是否发起仲裁`
1167
+ : '';
655
1168
  return `❌ 里程碑 M${p.milestoneIndex ?? '?'} 被拒绝
656
1169
  订单: ${p.orderId || body?.orderId || '?'}${desc}
657
- 原因: ${p.rejectReason || '未说明'}`;
1170
+ 原因: ${p.rejectReason || '未说明'}${arbitrationNote}`;
658
1171
  },
659
1172
  'order_completed': (p) => `📦 订单已提交完成
660
1173
  订单: ${p.orderId || body?.orderId || '?'}
@@ -704,24 +1217,15 @@ USDC 已支付`;
704
1217
  continue;
705
1218
  }
706
1219
  } else if (target.channel === 'gateway') {
707
- const gw = discoverGateway();
708
- if (gw?.url && gw?.token) {
709
- const resp = await fetch(`${gw.url}/tools/invoke`, {
710
- method: 'POST',
711
- headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${gw.token}` },
712
- body: JSON.stringify({ tool: 'message', args: { action: 'send', message, target: target.target } }),
713
- signal: AbortSignal.timeout(5000),
714
- });
715
- if (!resp.ok) {
716
- log({ event: 'trade_notify_delivery_error', eventType, channel: 'gateway', target: target.id || target.target, status: resp.status, error: `http_${resp.status}` });
717
- continue;
718
- }
719
- } else {
720
- log({ event: 'trade_notify_target_skipped', eventType, channel: 'gateway', target: target.id || target.target, reason: 'missing_gateway' });
1220
+ const delivery = await invokeGatewayTelegramMessage(target.target, message, 5000);
1221
+ if (!delivery.ok) {
1222
+ log({ event: 'trade_notify_delivery_error', eventType, channel: 'gateway', target: target.id || target.target, status: delivery.status || 0, error: delivery.error || delivery.reason || 'gateway_failed', gatewayUrl: delivery.url || null });
721
1223
  continue;
722
1224
  }
723
1225
  }
724
1226
  target.lastUsedAt = new Date().toISOString();
1227
+ markNotifyTargetUsed(targets, target, target.lastUsedAt);
1228
+ log({ event: 'trade_notify_delivered', eventType, channel: target.channel, target: target.id || target.target, lastUsedAt: target.lastUsedAt });
725
1229
  } catch (e) {
726
1230
  log({ event: 'trade_notify_delivery_error', eventType, channel: target.channel, target: target.id || target.target, error: e.message || 'unknown_error' });
727
1231
  }
@@ -771,41 +1275,33 @@ async function pushP2PNotification(eventType, payload = {}) {
771
1275
 
772
1276
  for (const target of enabled) {
773
1277
  try {
1278
+ const hasRichMedia = (Array.isArray(payload.images) && payload.images.length > 0) || (Array.isArray(payload.attachments) && payload.attachments.length > 0);
774
1279
  if (target.channel === 'telegram') {
775
1280
  if (!target.botToken) {
776
1281
  log({ event: 'p2p_notify_target_skipped', eventType, channel: 'telegram', target: target.id || target.target, reason: 'missing_bot_token' });
777
1282
  continue;
778
1283
  }
779
- const resp = await fetch(`https://api.telegram.org/bot${target.botToken}/sendMessage`, {
780
- method: 'POST',
781
- headers: { 'Content-Type': 'application/json' },
782
- body: JSON.stringify({ chat_id: target.target, text: message }),
783
- signal: AbortSignal.timeout(5000),
784
- });
785
- const data = await resp.json().catch(() => null);
786
- if (!resp.ok || data?.ok === false) {
787
- log({ event: 'p2p_notify_delivery_error', eventType, channel: 'telegram', target: target.id || target.target, status: resp.status, error: data?.description || `http_${resp.status}` });
788
- continue;
1284
+ if (hasRichMedia) {
1285
+ await sendTelegramRichMedia(target.botToken, target.target, message, payload, 5000);
1286
+ } else {
1287
+ await sendTelegramBotRequest(target.botToken, 'sendMessage', { chat_id: target.target, text: message }, 5000);
789
1288
  }
790
1289
  } else if (target.channel === 'gateway') {
791
- const gw = discoverGateway();
792
- if (gw?.url && gw?.token) {
793
- const resp = await fetch(`${gw.url}/tools/invoke`, {
794
- method: 'POST',
795
- headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${gw.token}` },
796
- body: JSON.stringify({ tool: 'message', args: { action: 'send', message, target: target.target } }),
797
- signal: AbortSignal.timeout(5000),
798
- });
799
- if (!resp.ok) {
800
- log({ event: 'p2p_notify_delivery_error', eventType, channel: 'gateway', target: target.id || target.target, status: resp.status, error: `http_${resp.status}` });
1290
+ const oc = loadOpenClawConfig();
1291
+ const botToken = String(oc?.channels?.telegram?.botToken || '').trim();
1292
+ if (hasRichMedia && botToken) {
1293
+ await sendTelegramRichMedia(botToken, target.target, message, payload, 5000);
1294
+ } else {
1295
+ const delivery = await invokeGatewayTelegramMessage(target.target, message, 5000);
1296
+ if (!delivery.ok) {
1297
+ log({ event: 'p2p_notify_delivery_error', eventType, channel: 'gateway', target: target.id || target.target, status: delivery.status || 0, error: delivery.error || delivery.reason || 'gateway_failed', gatewayUrl: delivery.url || null });
801
1298
  continue;
802
1299
  }
803
- } else {
804
- log({ event: 'p2p_notify_target_skipped', eventType, channel: 'gateway', target: target.id || target.target, reason: 'missing_gateway' });
805
- continue;
806
1300
  }
807
1301
  }
808
1302
  target.lastUsedAt = new Date().toISOString();
1303
+ markNotifyTargetUsed(targets, target, target.lastUsedAt);
1304
+ log({ event: 'p2p_notify_delivered', eventType, channel: target.channel, target: target.id || target.target, lastUsedAt: target.lastUsedAt });
809
1305
  } catch (e) {
810
1306
  log({ event: 'p2p_notify_delivery_error', eventType, channel: target.channel, target: target.id || target.target, error: e.message || 'unknown_error' });
811
1307
  }
@@ -1704,9 +2200,9 @@ async function getWalletAddresses() {
1704
2200
  function detectPreferredChain() {
1705
2201
  const config = loadAnchorConfig();
1706
2202
  if (config?.preferredChain) return config.preferredChain;
1707
- if (getChainPrivateKey('solana')) return 'solana';
1708
- if (getChainPrivateKey('base')) return 'base';
1709
2203
  if (getChainPrivateKey('bsc')) return 'bsc';
2204
+ if (getChainPrivateKey('base')) return 'base';
2205
+ if (getChainPrivateKey('solana')) return 'solana';
1710
2206
  return null;
1711
2207
  }
1712
2208
 
@@ -2864,7 +3360,7 @@ async function cmdAnchor(subcommand) {
2864
3360
 
2865
3361
  async function cmdInfo() {
2866
3362
  const id = requireIdentity();
2867
- const info = { agent_id: id.agent_id, did: id.did, capabilities: loadCapabilities(), policy: loadPolicy(), network: loadNetwork(), executor: EXECUTOR_URL || 'not configured' };
3363
+ const info = { agent_id: id.agent_id, did: id.did, capabilities: loadCapabilities(), policy: loadPolicy(), network: loadNetwork(), executor: EXECUTOR_URL || 'not configured', environment: getCurrentEnvironmentSummary() };
2868
3364
 
2869
3365
  // Fetch wallet info from Platform
2870
3366
  try {
@@ -2983,11 +3479,11 @@ async function cmdSetup(port) {
2983
3479
  const net = await autoNetworkSetup(p, ATEL_RELAY);
2984
3480
  for (const step of net.steps) console.log(JSON.stringify({ event: 'step', message: step }));
2985
3481
  if (net.endpoint) {
2986
- saveNetwork({ publicIP: net.publicIP, port: p, endpoint: net.endpoint, upnp: net.upnpSuccess, reachable: net.reachable, configuredAt: new Date().toISOString() });
3482
+ saveNetwork({ ...net, publicIP: net.publicIP, port: p, endpoint: net.endpoint, upnp: net.upnpSuccess, reachable: net.reachable, configuredAt: new Date().toISOString() });
2987
3483
  console.log(JSON.stringify({ status: 'ready', endpoint: net.endpoint }));
2988
3484
  } else if (net.publicIP) {
2989
3485
  const ep = `http://${net.publicIP}:${p}`;
2990
- saveNetwork({ publicIP: net.publicIP, port: p, endpoint: ep, upnp: false, reachable: false, needsManualPortForward: true, configuredAt: new Date().toISOString() });
3486
+ saveNetwork({ ...net, publicIP: net.publicIP, port: p, endpoint: ep, upnp: false, reachable: false, needsManualPortForward: true, configuredAt: new Date().toISOString() });
2991
3487
  console.log(JSON.stringify({ status: 'needs_port_forward', publicIP: net.publicIP, port: p, instruction: `Forward external TCP port ${p} to this machine's port ${p} on your router. Then run: atel verify` }));
2992
3488
  } else {
2993
3489
  console.log(JSON.stringify({ status: 'failed', error: 'Could not determine public IP' }));
@@ -3180,12 +3676,25 @@ async function cmdStart(port) {
3180
3676
  }
3181
3677
 
3182
3678
  const p = parseInt(port || '3100');
3679
+ const startLock = acquireStartInstanceLock(p);
3680
+ if (!startLock.ok) {
3681
+ console.error(`Another atel start instance is already running for this ATEL_DIR on port ${p} (pid: ${startLock.existing?.pid || 'unknown'}).`);
3682
+ process.exit(1);
3683
+ }
3684
+ process.on('exit', () => { releaseStartInstanceLock(p); });
3183
3685
  const caps = loadCapabilities();
3184
3686
  const capTypes = caps.map(c => c.type || c);
3185
3687
  const policy = loadPolicy();
3186
3688
  const enforcer = new PolicyEnforcer(policy);
3187
3689
  const pendingTasks = loadTasks();
3188
3690
  let resultPushQueue = loadResultPushQueue();
3691
+ const notifyTargets = loadNotifyTargets();
3692
+ const enabledNotifyTargets = notifyTargets.targets.filter(t => t.enabled !== false);
3693
+ if (notifyTargets.targets.length === 0) {
3694
+ console.warn('⚠️ No notification targets are bound. Run `atel notify bind <chatId>` or `atel notify add telegram <chatId>` before expecting callbacks.');
3695
+ } else if (enabledNotifyTargets.length === 0) {
3696
+ console.warn('⚠️ Notification targets exist, but all are disabled. Run `atel notify enable <id>` to restore callbacks.');
3697
+ }
3189
3698
 
3190
3699
  const sleep = (ms) => new Promise(r => setTimeout(r, ms));
3191
3700
 
@@ -3194,7 +3703,7 @@ async function cmdStart(port) {
3194
3703
  if (process.env.ATEL_DEBUG) console.error('[DEBUG] verifyAnchorFromChain input:', { chain, txHash, traceRoot });
3195
3704
 
3196
3705
  if (!txHash || !traceRoot) return { checked: false, verified: false, reason: 'missing_anchor_or_root' };
3197
- const c = (chain || 'solana').toLowerCase();
3706
+ const c = (chain || 'base').toLowerCase();
3198
3707
 
3199
3708
  if (c === 'solana') {
3200
3709
  const rpcUrl = process.env.ATEL_SOLANA_RPC_URL || 'https://api.mainnet-beta.solana.com';
@@ -3510,8 +4019,9 @@ async function cmdStart(port) {
3510
4019
  const processedEvents = new Set();
3511
4020
  const pendingAgentCallbacks = new Map();
3512
4021
 
3513
- // ── Agent hook queue (serialize hook executions to avoid session lock conflicts) ──
3514
- let hookBusy = false;
4022
+ // ── Agent hook queue (bounded concurrency, still guarded by recovery keys) ──
4023
+ let activeHookWorkers = 0;
4024
+ const MAX_HOOK_CONCURRENCY = Math.max(1, Math.min(4, Number.parseInt(process.env.ATEL_HOOK_CONCURRENCY || '3', 10) || 3));
3515
4025
  const hookQueue = [];
3516
4026
  const activeRecoveryKeys = new Set();
3517
4027
 
@@ -3837,9 +4347,17 @@ For rejection:
3837
4347
  Important: you are not chatting with the user.
3838
4348
  Do not output markdown, headings, bullet points, explanations, or your reasoning.
3839
4349
  You must output exactly one line of JSON.
4350
+ Do not return a plan-only answer.
4351
+ Do not return vague wording like “可定义为/应当/建议/可以/后续将”.
4352
+ You must prefer factual evidence:
4353
+ - what you actually checked
4354
+ - what command or file/path or interface you actually touched
4355
+ - what concrete output / state / observation you actually got
4356
+ - whether that evidence satisfies the current milestone
4357
+ If you could not find evidence, say that explicitly in the JSON result, including what you checked.
3840
4358
 
3841
4359
  Format:
3842
- {"result":"the actual deliverable for the current milestone"}`;
4360
+ {"result":"factual deliverable with concrete evidence and observations only"}`;
3843
4361
  }
3844
4362
 
3845
4363
  return promptText;
@@ -3965,6 +4483,9 @@ Format:
3965
4483
  const parsed = JSON.parse(cleaned);
3966
4484
  return buildAgentCallbackAction(eventType, payload, parsed);
3967
4485
  } catch {
4486
+ if (isKnownInvalidLocalAgentStdout(cleaned)) {
4487
+ return { ok: false, error: 'invalid_local_agent_stdout_known_failure' };
4488
+ }
3968
4489
  return buildAgentCallbackAction(eventType, payload, { result: cleaned, summary: cleaned });
3969
4490
  }
3970
4491
  }
@@ -4094,7 +4615,7 @@ Format:
4094
4615
  activeRecoveryKeys.add(recoveryKey);
4095
4616
  }
4096
4617
  hookQueue.push({ event: eventType, dedupeKey, cmd: parsedCmd[0], args: parsedCmd.slice(1), cwd, payload, recoveryKey });
4097
- if (!hookBusy) processHookQueue();
4618
+ processHookQueue();
4098
4619
  return true;
4099
4620
  }
4100
4621
 
@@ -4186,6 +4707,10 @@ Format:
4186
4707
  orderDescription,
4187
4708
  previousApprovedOutputs,
4188
4709
  });
4710
+ if (isResubmission && Number(currentMilestone.submitCount || 0) >= 3) {
4711
+ log({ event: 'trade_reconcile_executor_skip_manual_arbitration', orderId, currentMilestone: currentIndex, phase: ms.phase, submitCount: Number(currentMilestone.submitCount || 0) });
4712
+ return;
4713
+ }
4189
4714
  const promptText = isResubmission
4190
4715
  ? `You are the ATEL executor agent. Your previous submission for milestone M${currentIndex} was rejected.
4191
4716
  Original order requirements: ${orderDescription || 'not provided'}
@@ -4238,7 +4763,7 @@ Advance the current milestone strictly based on these approved results. Do not i
4238
4763
  orderDescription,
4239
4764
  previousApprovedOutputs,
4240
4765
  };
4241
- const promptText = `You are the ATEL requester agent. You need to review the work submitted by the executor.\nOriginal order requirements: ${orderDescription || 'not provided'}\nMilestone goal: ${submittedMilestone.title || ''}\nPreviously approved outputs:\n${previousApprovedOutputs || 'none'}\nSubmission: ${submittedMilestone.resultSummary || ''}\nDecide carefully whether to pass or reject based only on the order requirements and the previously approved outputs, then return decision=pass or decision=reject via the callback.`;
4766
+ const promptText = `You are the ATEL requester agent. You need to review the work submitted by the executor.\nOriginal order requirements: ${orderDescription || 'not provided'}\nMilestone goal: ${submittedMilestone.title || ''}\nPreviously approved outputs:\n${previousApprovedOutputs || 'none'}\nSubmission: ${submittedMilestone.resultSummary || ''}\nReview based on the submitted evidence, command outputs, file contents, and the previously approved outputs. Do not try to inspect executor-local filesystem paths like /tmp on your own machine unless the submission includes a reproducible proof that makes such a check valid.\nDecide carefully whether to pass or reject based only on the order requirements and the previously approved outputs, then return decision=pass or decision=reject via the callback.`;
4242
4767
  const recoveryKey = buildMilestoneHookRecoveryKey('milestone_submitted', payload);
4243
4768
  const queued = queueAgentHook('milestone_submitted', recoveryKey, promptText, workspace.dir, payload, { recoveryKey });
4244
4769
  if (queued) log({ event: 'trade_reconcile_requester', orderId, milestoneIndex: submittedMilestone.index, recoveryKey });
@@ -4290,7 +4815,13 @@ Advance the current milestone strictly based on these approved results. Do not i
4290
4815
  const order = await fetchOrderState(orderId);
4291
4816
  await reconcileSingleTradeOrder(order);
4292
4817
  } catch (e) {
4293
- log({ event: 'trade_reconcile_tracked_error', orderId, error: e.message });
4818
+ const message = String(e?.message || '');
4819
+ if (message.includes('order_info_http_404')) {
4820
+ untrackOrder(orderId);
4821
+ log({ event: 'trade_reconcile_tracked_removed', orderId, reason: 'order_info_http_404' });
4822
+ continue;
4823
+ }
4824
+ log({ event: 'trade_reconcile_tracked_error', orderId, error: message });
4294
4825
  }
4295
4826
  }
4296
4827
  }
@@ -4391,7 +4922,7 @@ Advance the current milestone strictly based on these approved results. Do not i
4391
4922
  if (kind === 'contact_added') {
4392
4923
  pushP2PNotification('p2p_contact_added', { peerDid: messageSenderDid || senderDid, alias: body.alias || '', text }).catch((e) => log({ event: 'p2p_notify_error', kind, error: e.message }));
4393
4924
  } else if (kind !== 'telegram_message') {
4394
- pushP2PNotification('p2p_message_received', { peerDid: messageSenderDid || senderDid, text }).catch((e) => log({ event: 'p2p_notify_error', kind, error: e.message }));
4925
+ pushP2PNotification('p2p_message_received', { peerDid: messageSenderDid || senderDid, text, images: body.images, attachments: body.attachments }).catch((e) => log({ event: 'p2p_notify_error', kind, error: e.message }));
4395
4926
  }
4396
4927
 
4397
4928
  res.json({ status: 'ok', kind });
@@ -4538,7 +5069,7 @@ Advance the current milestone strictly based on these approved results. Do not i
4538
5069
  + `## 原任务\n${fullDesc}\n\n`
4539
5070
  + `## 当前里程碑\nM${mIdx}: ${mDesc}\n\n`
4540
5071
  + `## 执行方提交(第 ${submitCount}/3 次)\n${subSummary}\n\n`
4541
- + `请基于你自己的判断,评估这次提交是否真实地完成了原任务在当前里程碑应有的部分。如果符合原任务的要求和意图,PASS;如果偏离原任务、违反原任务的约束、或没有真正交付要求的内容,REJECT。\n\n`
5072
+ + `请基于你自己的判断,评估这次提交是否真实地完成了原任务在当前里程碑应有的部分。如果符合原任务的要求和意图,PASS;如果偏离原任务、违反原任务的约束、或没有真正交付要求的内容,REJECT。评审时优先依据执行方提交的证据、命令输出、文件内容与前序已批准结果;不要在你自己的机器上直接检查执行方本地 /tmp 等路径,除非提交里已经给出可复现且合理的远端证明。\n\n`
4542
5073
  + `通过:\ncd ~/atel-workspace && atel milestone-verify ${orderIdForCwd} ${mIdx} --pass\n\n`
4543
5074
  + `不通过(必须给出具体理由):\ncd ~/atel-workspace && atel milestone-verify ${orderIdForCwd} ${mIdx} --reject '具体原因'\n`;
4544
5075
  log({ event: 'verifier_prompt_overridden', orderId: orderIdForCwd, milestoneIndex: mIdx });
@@ -4610,7 +5141,11 @@ Advance the current milestone strictly based on these approved results. Do not i
4610
5141
  // to "say" it will run a command. This is the reliable path for state-transition actions
4611
5142
  // such as approving the milestone plan on order acceptance.
4612
5143
  let directExecutionSucceeded = false;
4613
- const directActions = getDirectExecutableActions(event, recommendedActions);
5144
+ const rejectLimitReached = event === 'milestone_rejected' && Number(payload?.submitCount || body?.submitCount || 0) >= 3;
5145
+ const directActions = rejectLimitReached ? [] : getDirectExecutableActions(event, recommendedActions, payload, currentPolicy);
5146
+ if (rejectLimitReached) {
5147
+ log({ event: 'direct_action_skip_manual_arbitration', eventType: event, dedupeKey, orderId: payload?.orderId || body?.orderId || '', milestoneIndex: payload?.milestoneIndex ?? body?.milestoneIndex ?? null, reason: 'rejection_limit_reached' });
5148
+ }
4614
5149
  for (const action of directActions) {
4615
5150
  const result = await executeRecommendedActionDirect(event, action, atelCwd, dedupeKey);
4616
5151
  if (result.ok) directExecutionSucceeded = true;
@@ -4623,6 +5158,18 @@ Advance the current milestone strictly based on these approved results. Do not i
4623
5158
  const autoTriggerEvents = ['order_accepted', 'milestone_plan_confirmed', 'milestone_submitted', 'milestone_verified', 'milestone_rejected'];
4624
5159
  const hasGatewayAction = Array.isArray(recommendedActions) && recommendedActions.some((action) => Array.isArray(action?.command) && action.command[0] === 'atel');
4625
5160
  if (agentCmd && prompt && autoTriggerEvents.includes(event) && !shouldSkipAgentHook(event, directExecutionSucceeded)) {
5161
+ if (rejectLimitReached) {
5162
+ log({
5163
+ event: 'agent_hook_skip_manual_arbitration',
5164
+ eventType: event,
5165
+ dedupeKey,
5166
+ orderId: payload?.orderId || body?.orderId || '',
5167
+ milestoneIndex: payload?.milestoneIndex ?? body?.milestoneIndex ?? null,
5168
+ reason: 'rejection_limit_reached',
5169
+ });
5170
+ res.json({ status: 'received', eventId, eventType: event, skipped: true, requiresManualArbitration: true });
5171
+ return;
5172
+ }
4626
5173
  const needsActionablePayload = event !== 'milestone_submitted';
4627
5174
  if (needsActionablePayload && !hasGatewayAction) {
4628
5175
  log({ event: 'agent_hook_skip_no_action', eventType: event, dedupeKey, reason: 'informational_only_payload' });
@@ -4667,13 +5214,16 @@ Advance the current milestone strictly based on these approved results. Do not i
4667
5214
 
4668
5215
  // Process hook queue serially
4669
5216
  async function processHookQueue() {
4670
- if (hookBusy || hookQueue.length === 0) return;
4671
- hookBusy = true;
5217
+ if (activeHookWorkers >= MAX_HOOK_CONCURRENCY || hookQueue.length === 0) return;
5218
+ activeHookWorkers += 1;
4672
5219
  const { event: hookEvent, dedupeKey: hookKey, cmd: spawnCmd, args: spawnArgs, cwd: hookCwd, payload: hookPayload, recoveryKey } = hookQueue.shift();
5220
+ if (activeHookWorkers < MAX_HOOK_CONCURRENCY && hookQueue.length > 0) {
5221
+ processHookQueue();
5222
+ }
4673
5223
  const { execFile } = await import('child_process');
4674
5224
  const finishHook = () => {
4675
5225
  if (recoveryKey) activeRecoveryKeys.delete(recoveryKey);
4676
- hookBusy = false;
5226
+ activeHookWorkers = Math.max(0, activeHookWorkers - 1);
4677
5227
  processHookQueue();
4678
5228
  };
4679
5229
  log({ event: 'agent_cmd_trigger', eventType: hookEvent, dedupeKey: hookKey, cmd: spawnCmd, argsCount: spawnArgs.length });
@@ -4695,7 +5245,7 @@ Advance the current milestone strictly based on these approved results. Do not i
4695
5245
 
4696
5246
  const MAX_ATTEMPTS = 5;
4697
5247
  const isMilestoneHook = ['milestone_plan_confirmed', 'milestone_verified', 'milestone_rejected', 'milestone_submitted'].includes(hookEvent);
4698
- const localHookTimeoutMs = isMilestoneHook ? 180000 : 600000;
5248
+ const localHookTimeoutMs = isMilestoneHook ? 90000 : 600000;
4699
5249
  const preparedInvocation = prepareHookInvocation(spawnCmd, spawnArgs, hookKey, Math.ceil(localHookTimeoutMs / 1000));
4700
5250
  const runHook = (attempt, invocation = preparedInvocation) => {
4701
5251
  const hookStartedAt = Date.now();
@@ -4707,7 +5257,7 @@ Advance the current milestone strictly based on these approved results. Do not i
4707
5257
 
4708
5258
  if (isSessionLock && attempt < MAX_ATTEMPTS) {
4709
5259
  // OpenClaw session still held by previous call — wait for lock release then retry
4710
- const delay = 15000 + (attempt * 5000); // 15s, 20s, 25s, 30s
5260
+ const delay = 3000 + (attempt * 2000); // 3s, 5s, 7s, 9s
4711
5261
  log({ event: 'agent_cmd_session_lock', eventType: hookEvent, attempt: attempt + 1, delayMs: delay, duration_ms: durationMs });
4712
5262
  setTimeout(() => runHook(attempt + 1), delay);
4713
5263
  } else if (isNetworkError && attempt < 2) {
@@ -4901,7 +5451,7 @@ Advance the current milestone strictly based on these approved results. Do not i
4901
5451
  const proofRecord = {
4902
5452
  traceRoot: proof.trace_root,
4903
5453
  txHash: anchor.txHash,
4904
- chain: anchor.chain || 'solana',
5454
+ chain: anchor.chain || 'base',
4905
5455
  executor: id.did,
4906
5456
  taskFrom: task.from,
4907
5457
  action: task.action,
@@ -5004,7 +5554,7 @@ Advance the current milestone strictly based on these approved results. Do not i
5004
5554
  const anchorTx = anchor?.txHash || null;
5005
5555
  let anchorAudit = { checked: false, verified: false, chain: task?.chain || anchor?.chain || null, reason: null };
5006
5556
  if (anchorTx) {
5007
- anchorAudit = await verifyAnchorFromChain(task?.chain || anchor?.chain || 'solana', anchorTx, proof.trace_root);
5557
+ anchorAudit = await verifyAnchorFromChain(task?.chain || anchor?.chain || 'base', anchorTx, proof.trace_root);
5008
5558
  }
5009
5559
 
5010
5560
  const auditReasons = [];
@@ -5038,7 +5588,7 @@ Advance the current milestone strictly based on these approved results. Do not i
5038
5588
  proofBundle: proof,
5039
5589
  traceRoot: proof.trace_root,
5040
5590
  anchorTx: anchor?.txHash || null,
5041
- chain: anchor?.chain || task?.chain || 'solana',
5591
+ chain: anchor?.chain || task?.chain || 'base',
5042
5592
  traceEvents: trace.events, // Include trace events for verification
5043
5593
  audit: auditSummary,
5044
5594
  };
@@ -5088,7 +5638,7 @@ Advance the current milestone strictly based on these approved results. Do not i
5088
5638
  fetch(`${ATEL_NOTIFY_GATEWAY}/tools/invoke`, {
5089
5639
  method: 'POST',
5090
5640
  headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${token}` },
5091
- body: JSON.stringify({ tool: 'message', args: { action: 'send', message: msg, target: ATEL_NOTIFY_TARGET } }),
5641
+ body: JSON.stringify({ tool: 'message', args: { action: 'send', channel: 'telegram', message: msg, target: ATEL_NOTIFY_TARGET } }),
5092
5642
  signal: AbortSignal.timeout(5000),
5093
5643
  }).then(() => log({ event: 'notify_sent', taskId })).catch(e => log({ event: 'notify_failed', taskId, error: e.message }));
5094
5644
  }
@@ -5120,7 +5670,7 @@ Advance the current milestone strictly based on these approved results. Do not i
5120
5670
  status: (success !== false && auditPassed) ? 'completed' : 'failed',
5121
5671
  result,
5122
5672
  proof: { proof_id: proof.proof_id, trace_root: proof.trace_root, events_count: trace.events.length },
5123
- anchor: anchor ? { chain: anchor.chain || 'solana', txHash: anchor.txHash, block: anchor.blockNumber } : null,
5673
+ anchor: anchor ? { chain: anchor.chain || 'base', txHash: anchor.txHash, block: anchor.blockNumber } : null,
5124
5674
  execution: { duration_ms: durationMs, encrypted: task.encrypted },
5125
5675
  audit: auditSummary,
5126
5676
  rollback: rollbackReport ? { total: rollbackReport.total, succeeded: rollbackReport.succeeded, failed: rollbackReport.failed } : null,
@@ -5371,7 +5921,7 @@ Advance the current milestone strictly based on these approved results. Do not i
5371
5921
  text,
5372
5922
  timestamp: new Date().toISOString(),
5373
5923
  });
5374
- pushP2PNotification('p2p_message_received', { peerDid: message.from, text }).catch((e) => log({ event: 'p2p_notify_error', kind: 'portal_message', error: e.message }));
5924
+ pushP2PNotification('p2p_message_received', { peerDid: message.from, text, images: payload.images, attachments: payload.attachments }).catch((e) => log({ event: 'p2p_notify_error', kind: 'portal_message', error: e.message }));
5375
5925
  return {
5376
5926
  status: 'ok',
5377
5927
  kind: 'portal_message',
@@ -5641,7 +6191,7 @@ Advance the current milestone strictly based on these approved results. Do not i
5641
6191
  const echoDurationMs = Date.now() - new Date(echoAcceptedAt || Date.now()).getTime();
5642
6192
  if (anchor?.txHash) {
5643
6193
  const proofRecord = {
5644
- traceRoot: proof.trace_root, txHash: anchor.txHash, chain: anchor.chain || 'solana',
6194
+ traceRoot: proof.trace_root, txHash: anchor.txHash, chain: anchor.chain || 'base',
5645
6195
  executor: id.did, taskFrom: message.from, action, success: echoSuccess,
5646
6196
  durationMs: echoDurationMs, riskLevel: 'low', policyViolations: 0,
5647
6197
  proofId: proof.proof_id, timestamp: new Date().toISOString(), verified: true,
@@ -5677,10 +6227,8 @@ Advance the current milestone strictly based on these approved results. Do not i
5677
6227
 
5678
6228
  await endpoint.start();
5679
6229
 
5680
- // Telegram notifications are opt-in by default. Only auto-bind when explicit consent was already collected.
5681
- if (ATEL_NOTIFY_AUTO_BIND) {
5682
- try { autoBindNotifications(); } catch (e) { /* never block startup */ }
5683
- }
6230
+ // In OpenClaw-hosted mode, self-heal notification binding automatically so TG callbacks survive reinstall / port drift.
6231
+ try { autoBindNotifications(); } catch (e) { /* never block startup */ }
5684
6232
 
5685
6233
  // Background retry for failed result pushes (durable queue)
5686
6234
  const flushResultPushQueue = async () => {
@@ -5721,12 +6269,12 @@ Advance the current milestone strictly based on these approved results. Do not i
5721
6269
  flushResultPushQueue().catch((e) => log({ event: 'result_push_flush_error', error: e.message }));
5722
6270
  setInterval(() => {
5723
6271
  flushResultPushQueue().catch((e) => log({ event: 'result_push_flush_error', error: e.message }));
5724
- }, 15000);
6272
+ }, 5000);
5725
6273
 
5726
6274
  reconcileActiveTradeOrders().catch((e) => log({ event: 'trade_reconcile_bootstrap_error', error: e.message }));
5727
6275
  setInterval(() => {
5728
6276
  reconcileActiveTradeOrders().catch((e) => log({ event: 'trade_reconcile_interval_error', error: e.message }));
5729
- }, 15000);
6277
+ }, 3000);
5730
6278
 
5731
6279
  // Auto-register to Registry with candidates
5732
6280
  if (capTypes.length > 0 && networkConfig.candidates && networkConfig.candidates.length > 0) {
@@ -6736,14 +7284,14 @@ async function cmdRotate() {
6736
7284
  newDid: newIdentity.did,
6737
7285
  backup: backupFile,
6738
7286
  proof_valid: verifyKeyRotation(proof),
6739
- anchor: anchor ? { chain: anchor.chain || 'solana', txHash: anchor.txHash } : null,
7287
+ anchor: anchor ? { chain: anchor.chain || 'base', txHash: anchor.txHash } : null,
6740
7288
  next: 'Restart endpoint: atel start [port]',
6741
7289
  }, null, 2));
6742
7290
  }
6743
7291
 
6744
7292
  // ─── Platform API Helpers ────────────────────────────────────────
6745
7293
 
6746
- const PLATFORM_URL = process.env.ATEL_PLATFORM || process.env.ATEL_API || process.env.ATEL_REGISTRY || 'https://api.atelai.org';
7294
+ const PLATFORM_URL = ATEL_PLATFORM;
6747
7295
 
6748
7296
  async function signedFetch(method, path, payload = {}) {
6749
7297
  const id = requireIdentity();
@@ -6769,6 +7317,7 @@ async function cmdAuth(code) {
6769
7317
  console.error(' Authorize a Dashboard session using the code displayed on the login page.');
6770
7318
  process.exit(1);
6771
7319
  }
7320
+ ensureProductionAuthTarget();
6772
7321
  const id = requireIdentity();
6773
7322
  const { default: nacl } = await import('tweetnacl');
6774
7323
  const { serializePayload } = await import('@lawrenceliang-btc/atel-sdk');
@@ -7268,10 +7817,39 @@ async function cmdReject(orderId) {
7268
7817
  console.log(JSON.stringify(data, null, 2));
7269
7818
  }
7270
7819
 
7271
- async function cmdOrderCancel(orderId, reason) {
7272
- if (!orderId) { console.error('Usage: atel order-cancel <orderId> [reason]'); process.exit(1); }
7820
+ async function cmdOrderCancel(orderId, restArgs = []) {
7821
+ if (!orderId) { console.error('Usage: atel order-cancel <orderId> [reason] [--dry-run]'); process.exit(1); }
7822
+
7823
+ const { dryRun, reason } = parseOrderCancelArgs(restArgs);
7824
+ const cancelReason = reason || 'reset regression environment';
7825
+
7826
+ const res = await fetch(`${PLATFORM_URL}/trade/v1/order/${orderId}`, { signal: AbortSignal.timeout(10000) });
7827
+ const orderInfo = await res.json();
7828
+ if (!res.ok) { console.error('Failed to get order:', orderInfo.error || `http_${res.status}`); process.exit(1); }
7829
+
7830
+ const currentDid = requireIdentity().did;
7831
+ const preflight = preflightOrderCancel(orderInfo, currentDid);
7832
+ if (!preflight.ok) {
7833
+ console.error(preflight.error);
7834
+ process.exit(1);
7835
+ }
7836
+
7837
+ if (dryRun) {
7838
+ console.log(JSON.stringify({
7839
+ status: 'dry_run',
7840
+ orderId: preflight.orderId,
7841
+ orderStatus: preflight.orderStatus,
7842
+ requesterDid: preflight.requesterDid,
7843
+ executorDid: preflight.executorDid,
7844
+ currentDid: preflight.currentDid,
7845
+ reason: cancelReason,
7846
+ canCancel: true,
7847
+ }, null, 2));
7848
+ return;
7849
+ }
7850
+
7273
7851
  const data = await signedFetch('POST', `/trade/v1/order/${orderId}/cancel`, {
7274
- reason: reason || 'reset regression environment',
7852
+ reason: cancelReason,
7275
7853
  });
7276
7854
  if (data?.status === 'cancelled') untrackOrder(orderId);
7277
7855
  console.log(JSON.stringify(data, null, 2));
@@ -7574,7 +8152,7 @@ async function cmdComplete(orderId, taskId) {
7574
8152
 
7575
8153
  if (anchor?.txHash) {
7576
8154
  const proofRecord = {
7577
- traceRoot: proof.trace_root, txHash: anchor.txHash, chain: anchor.chain || 'solana',
8155
+ traceRoot: proof.trace_root, txHash: anchor.txHash, chain: anchor.chain || 'base',
7578
8156
  executor: id.did, taskFrom: requesterDid, action: 'cli-complete',
7579
8157
  success: true, durationMs: 0, riskLevel: 'low', policyViolations: 0,
7580
8158
  proofId: proof.proof_id, timestamp: new Date().toISOString(), verified: true,
@@ -7621,7 +8199,7 @@ async function cmdComplete(orderId, taskId) {
7621
8199
  const orderPrice = Number(orderInfo?.priceAmount ?? 0);
7622
8200
  const isPaidOrder = orderId.startsWith('ord-') && orderPrice > 0;
7623
8201
  const anchorTx = anchor?.txHash || null;
7624
- const anchorChain = anchorTx ? (anchor?.chain || orderInfo?.chain || 'solana') : (orderInfo?.chain || null);
8202
+ const anchorChain = anchorTx ? (anchor?.chain || orderInfo?.chain || 'base') : (orderInfo?.chain || null);
7625
8203
  const auditReasons = [];
7626
8204
  if (!traceAudit.valid) auditReasons.push('trace_hash_chain_invalid');
7627
8205
  if (isPaidOrder && !anchorTx) auditReasons.push('paid_order_anchor_missing');
@@ -9597,7 +10175,7 @@ const commands = {
9597
10175
  // Trade
9598
10176
  'trade-task': () => cmdTradeTask(args[0], args.slice(1).join(' ')),
9599
10177
  order: () => cmdOrder(args[0], args[1], args[2]),
9600
- 'order-cancel': () => cmdOrderCancel(args[0], args.slice(1).join(' ').trim()),
10178
+ 'order-cancel': () => cmdOrderCancel(args[0], args.slice(1)),
9601
10179
  'order-info': () => cmdOrderInfo(args[0]),
9602
10180
  accept: () => cmdAccept(args[0]),
9603
10181
  reject: () => _origCmdReject(args[0]),
@@ -9745,13 +10323,28 @@ const commands = {
9745
10323
  console.log('Gateway:', gw ? `${gw.url} (token: ${gw.token ? '✅' : '❌'})` : '❌ not found');
9746
10324
  const botToken = discoverTelegramBot();
9747
10325
  console.log('TG Bot:', botToken ? `✅ (${botToken.split(':')[0]})` : '❌ not configured');
10326
+ const inboundConfig = getTelegramInboundConfig();
10327
+ const inboundMode = inboundConfig ? getTelegramInboundHostMode(inboundConfig) : { mode: 'disabled', reason: 'no_inbound_config' };
10328
+ console.log('TG Ingress:', `${inboundMode.mode}${inboundMode.reason ? ` (${inboundMode.reason})` : ''}`);
10329
+ let currentDid = '';
10330
+ try { currentDid = requireIdentity().did; } catch {}
10331
+ const consents = listNotifyConsentsForDid(currentDid);
9748
10332
  console.log(`\nTargets (${targets.targets.length}):`);
9749
10333
  if (targets.targets.length === 0) {
9750
10334
  console.log(' (none) — use "atel notify bind <chatId>" to add');
9751
10335
  }
9752
10336
  for (const t of targets.targets) {
9753
10337
  const status = t.enabled !== false ? '✅' : '🔇';
9754
- console.log(` ${status} [${t.id}] ${t.channel}:${t.target} label=${t.label || '-'} last=${t.lastUsedAt || 'never'}`);
10338
+ const consent = consents.find((item) => ((item.channel === t.channel) || (t.channel === 'gateway' && item.channel === 'telegram')) && item.target === String(t.target));
10339
+ const consentLabel = consent ? `${consent.status || 'active'} @ ${consent.consentedAt || consent.updatedAt || 'unknown'}` : 'missing';
10340
+ console.log(` ${status} [${t.id}] ${t.channel}:${t.target} label=${t.label || '-'} last=${t.lastUsedAt || 'never'} consent=${consentLabel}`);
10341
+ }
10342
+ if (currentDid) {
10343
+ console.log(`\nConsent Records (${consents.length}) for ${currentDid}:`);
10344
+ if (consents.length === 0) console.log(' (none)');
10345
+ for (const item of consents) {
10346
+ console.log(` - ${item.channel}:${item.target} status=${item.status || 'active'} source=${item.source || '-'} updated=${item.updatedAt || item.consentedAt || 'unknown'}`);
10347
+ }
9755
10348
  }
9756
10349
  return;
9757
10350
  }
@@ -9772,18 +10365,32 @@ const commands = {
9772
10365
  let botToken = rawArgs.includes('--bot-token') ? rawArgs[rawArgs.indexOf('--bot-token') + 1] : '';
9773
10366
  if (!botToken) botToken = process.env.TELEGRAM_BOT_TOKEN || '';
9774
10367
  if (!botToken) botToken = discoverTelegramBot() || '';
9775
- const id = `tg_${chatId}`;
9776
- // Remove existing with same id
9777
- targets.targets = targets.targets.filter(t => t.id !== id);
10368
+ const currentDid = requireIdentity().did;
10369
+ const bindingTarget = resolveNotifyBindTarget(chatId, botToken);
10370
+ if (bindingTarget.channel === 'telegram') {
10371
+ const ownership = ensureTelegramBindingOwnership(currentDid, chatId, botToken);
10372
+ if (!ownership.ok) {
10373
+ console.error(`Telegram chat ${chatId} for bot ${ownership.botId || 'telegram'} is already owned by DID ${ownership.ownerDid}. Remove/disable it there before rebinding.`);
10374
+ process.exit(1);
10375
+ }
10376
+ }
10377
+ const id = bindingTarget.id;
10378
+ // Remove existing notify targets for the same chat before rebinding
10379
+ targets.targets = targets.targets.filter(t => !(String(t.target) === String(chatId) && (t.channel === 'telegram' || t.channel === 'gateway')));
9778
10380
  targets.targets.push({
9779
- id, channel: 'telegram', target: String(chatId),
9780
- botToken: botToken || undefined,
9781
- label: 'owner', enabled: true,
10381
+ id, channel: bindingTarget.channel, target: String(bindingTarget.target),
10382
+ botToken: bindingTarget.botToken,
10383
+ label: bindingTarget.label, enabled: true,
9782
10384
  createdAt: new Date().toISOString(), lastUsedAt: null,
9783
10385
  });
9784
10386
  saveNotifyTargets(targets);
9785
10387
  rememberTelegramRoute(chatId, botToken || undefined);
9786
- console.log(`✅ Bound TG chat ${chatId} as notification target (id: ${id})`);
10388
+ try { upsertNotifyConsent({ did: requireIdentity().did, channel: 'telegram', target: String(chatId), botToken, source: 'notify_bind', status: 'active' }); } catch {}
10389
+ if (bindingTarget.channel === 'gateway') {
10390
+ console.log(`✅ Bound OpenClaw gateway chat ${chatId} as notification target (id: ${id})`);
10391
+ } else {
10392
+ console.log(`✅ Bound TG chat ${chatId} as notification target (id: ${id})`);
10393
+ }
9787
10394
  if (!botToken) console.log('⚠️ No bot token found. Set TELEGRAM_BOT_TOKEN or use --bot-token');
9788
10395
  return;
9789
10396
  }
@@ -9795,6 +10402,17 @@ const commands = {
9795
10402
  if (rawArgs.includes('--label')) label = rawArgs[rawArgs.indexOf('--label') + 1] || '';
9796
10403
  let botToken = rawArgs.includes('--bot-token') ? rawArgs[rawArgs.indexOf('--bot-token') + 1] : '';
9797
10404
  if (!botToken && channel === 'telegram') botToken = process.env.TELEGRAM_BOT_TOKEN || discoverTelegramBot() || '';
10405
+ const currentDid = requireIdentity().did;
10406
+ if (channel === 'telegram') {
10407
+ const bindingTarget = resolveNotifyBindTarget(target, botToken);
10408
+ if (bindingTarget.channel === 'telegram') {
10409
+ const ownership = ensureTelegramBindingOwnership(currentDid, target, botToken);
10410
+ if (!ownership.ok) {
10411
+ console.error(`Telegram chat ${target} for bot ${ownership.botId || 'telegram'} is already owned by DID ${ownership.ownerDid}. Remove/disable it there before rebinding.`);
10412
+ process.exit(1);
10413
+ }
10414
+ }
10415
+ }
9798
10416
  const id = `${channel.substring(0,2)}_${target}`;
9799
10417
  targets.targets = targets.targets.filter(t => t.id !== id);
9800
10418
  targets.targets.push({
@@ -9804,6 +10422,7 @@ const commands = {
9804
10422
  createdAt: new Date().toISOString(), lastUsedAt: null,
9805
10423
  });
9806
10424
  saveNotifyTargets(targets);
10425
+ try { upsertNotifyConsent({ did: requireIdentity().did, channel, target: String(target), botToken, source: 'notify_add', status: 'active' }); } catch {}
9807
10426
  console.log(`✅ Added ${channel}:${target} (id: ${id})`);
9808
10427
  return;
9809
10428
  }
@@ -9811,9 +10430,17 @@ const commands = {
9811
10430
  if (subCmd === 'remove') {
9812
10431
  const id = args[1];
9813
10432
  if (!id) { console.error('Usage: atel notify remove <id>'); process.exit(1); }
10433
+ const removedTarget = targets.targets.find(t => t.id === id) || null;
9814
10434
  const before = targets.targets.length;
9815
10435
  targets.targets = targets.targets.filter(t => t.id !== id);
9816
10436
  saveNotifyTargets(targets);
10437
+ if (removedTarget) {
10438
+ try {
10439
+ const did = requireIdentity().did;
10440
+ if (removedTarget.channel === 'telegram') releaseTelegramBindingOwnership(did, removedTarget.target, removedTarget.botToken);
10441
+ revokeNotifyConsent({ did, channel: removedTarget.channel, target: String(removedTarget.target), reason: 'notify_remove' });
10442
+ } catch {}
10443
+ }
9817
10444
  console.log(targets.targets.length < before ? `✅ Removed ${id}` : `⚠️ Target ${id} not found`);
9818
10445
  return;
9819
10446
  }
@@ -9823,8 +10450,26 @@ const commands = {
9823
10450
  if (!id) { console.error(`Usage: atel notify ${subCmd} <id>`); process.exit(1); }
9824
10451
  const t = targets.targets.find(t => t.id === id);
9825
10452
  if (!t) { console.error(`Target ${id} not found`); process.exit(1); }
10453
+ const did = requireIdentity().did;
10454
+ if (subCmd === 'enable' && t.channel === 'telegram') {
10455
+ const bindingTarget = resolveNotifyBindTarget(t.target, t.botToken || '');
10456
+ if (bindingTarget.channel === 'telegram') {
10457
+ const ownership = ensureTelegramBindingOwnership(did, t.target, t.botToken);
10458
+ if (!ownership.ok) {
10459
+ console.error(`Telegram chat ${t.target} for bot ${ownership.botId || 'telegram'} is already owned by DID ${ownership.ownerDid}. Remove/disable it there before rebinding.`);
10460
+ process.exit(1);
10461
+ }
10462
+ }
10463
+ }
9826
10464
  t.enabled = subCmd === 'enable';
9827
10465
  saveNotifyTargets(targets);
10466
+ try {
10467
+ if (subCmd === 'enable') upsertNotifyConsent({ did, channel: t.channel, target: String(t.target), botToken: t.botToken, source: 'notify_enable', status: 'active' });
10468
+ else {
10469
+ if (t.channel === 'telegram') releaseTelegramBindingOwnership(did, t.target, t.botToken);
10470
+ revokeNotifyConsent({ did, channel: t.channel, target: String(t.target), reason: 'notify_disable' });
10471
+ }
10472
+ } catch {}
9828
10473
  console.log(`✅ ${id} ${subCmd}d`);
9829
10474
  return;
9830
10475
  }
@@ -9845,17 +10490,11 @@ const commands = {
9845
10490
  const data = await resp.json();
9846
10491
  console.log(` ${target.id}: ${data.ok ? '✅ sent' : '❌ ' + (data.description || 'failed')}`);
9847
10492
  } else if (target.channel === 'gateway') {
9848
- const gw = discoverGateway();
9849
- if (gw?.url && gw?.token) {
9850
- const resp = await fetch(`${gw.url}/tools/invoke`, {
9851
- method: 'POST',
9852
- headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${gw.token}` },
9853
- body: JSON.stringify({ tool: 'message', args: { action: 'send', message: '🔔 ATEL notification test', target: target.target } }),
9854
- signal: AbortSignal.timeout(10000),
9855
- });
9856
- console.log(` ${target.id}: ${resp.ok ? '✅ sent' : '❌ status ' + resp.status}`);
10493
+ const delivery = await invokeGatewayTelegramMessage(target.target, '🔔 ATEL notification test', 10000);
10494
+ if (delivery.ok) {
10495
+ console.log(` ${target.id}: sent via ${delivery.url}`);
9857
10496
  } else {
9858
- console.log(` ${target.id}: ❌ gateway not found`);
10497
+ console.log(` ${target.id}: ❌ ${delivery.error || delivery.reason || 'gateway_failed'}${delivery.url ? ' @ ' + delivery.url : ''}`);
9859
10498
  }
9860
10499
  } else {
9861
10500
  console.log(` ${target.id}: ❌ unsupported channel ${target.channel}`);
@@ -9946,7 +10585,7 @@ Hub Commands:
9946
10585
  Trade Commands:
9947
10586
  trade-task <cap> <desc> [--budget N] One-shot: search → order → wait → confirm (requester)
9948
10587
  order <executorDid> <cap> <price> Create a trade order
9949
- order-cancel <orderId> [reason] Cancel an order
10588
+ order-cancel <orderId> [reason] [--dry-run] Cancel an order
9950
10589
  order-info <orderId> Get order details
9951
10590
  accept <orderId> Accept an order (executor)
9952
10591
  reject <orderId> Reject an order (executor)