nothumanallowed 15.1.42 → 15.1.44
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/package.json +1 -1
- package/src/constants.mjs +1 -1
- package/src/server/routes/email.mjs +36 -0
- package/src/services/email-imap.mjs +72 -4
- package/src/services/email-smtp.mjs +49 -2
- package/src/services/message-responder.mjs +221 -0
- package/src/ui-dist/assets/{index-DLvIdPa_.js → index-DTy-ohQm.js} +52 -52
- package/src/ui-dist/index.html +1 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "nothumanallowed",
|
|
3
|
-
"version": "15.1.
|
|
3
|
+
"version": "15.1.44",
|
|
4
4
|
"description": "NotHumanAllowed — 38 AI agents, 80 tools, Studio (visual agentic workflows). Email, calendar, browser automation, screen capture, canvas, cron/heartbeat, Alexandria E2E messaging, GitHub, Notion, Slack, voice chat, free AI (Liara), 28 languages. Zero-dependency CLI.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
package/src/constants.mjs
CHANGED
|
@@ -5,7 +5,7 @@ import { fileURLToPath } from 'url';
|
|
|
5
5
|
const __filename = fileURLToPath(import.meta.url);
|
|
6
6
|
const __dirname = path.dirname(__filename);
|
|
7
7
|
|
|
8
|
-
export const VERSION = '15.1.
|
|
8
|
+
export const VERSION = '15.1.44';
|
|
9
9
|
export const BASE_URL = 'https://nothumanallowed.com/cli';
|
|
10
10
|
export const API_BASE = 'https://nothumanallowed.com/api/v1';
|
|
11
11
|
|
|
@@ -416,6 +416,42 @@ export function register(router) {
|
|
|
416
416
|
} catch (e) { sendError(res, 500, e.message); }
|
|
417
417
|
});
|
|
418
418
|
|
|
419
|
+
// POST /api/imap/test — dry-run connection test (no DB writes).
|
|
420
|
+
// Body: { imap_host, imap_port, smtp_host, smtp_port, username, password,
|
|
421
|
+
// email_address, from_name, sendTest? }
|
|
422
|
+
// Returns: { imap: {ok, secure, ...}, smtp: {ok, secure, sent, messageId, ...} }
|
|
423
|
+
// The UI uses this for the "Test connection" / "Send test email" buttons
|
|
424
|
+
// before persisting an account — same UX as Outlook / Thunderbird.
|
|
425
|
+
router.post('/api/imap/test', async (req, res) => {
|
|
426
|
+
try {
|
|
427
|
+
const body = await parseBody(req);
|
|
428
|
+
const creds = {
|
|
429
|
+
imap_host: body.imap_host,
|
|
430
|
+
imap_port: body.imap_port,
|
|
431
|
+
smtp_host: body.smtp_host,
|
|
432
|
+
smtp_port: body.smtp_port,
|
|
433
|
+
username: body.username,
|
|
434
|
+
password: body.password,
|
|
435
|
+
email_address: body.email_address,
|
|
436
|
+
from_name: body.from_name,
|
|
437
|
+
};
|
|
438
|
+
const out = { imap: null, smtp: null };
|
|
439
|
+
try {
|
|
440
|
+
const { testImapConnection } = await import('../../services/email-imap.mjs');
|
|
441
|
+
out.imap = await testImapConnection(creds);
|
|
442
|
+
} catch (e) {
|
|
443
|
+
out.imap = { ok: false, error: e.message };
|
|
444
|
+
}
|
|
445
|
+
try {
|
|
446
|
+
const { testSmtpConnection } = await import('../../services/email-smtp.mjs');
|
|
447
|
+
out.smtp = await testSmtpConnection(creds, { sendTest: !!body.sendTest });
|
|
448
|
+
} catch (e) {
|
|
449
|
+
out.smtp = { ok: false, error: e.message };
|
|
450
|
+
}
|
|
451
|
+
sendJSON(res, 200, out);
|
|
452
|
+
} catch (e) { sendError(res, 500, e.message); }
|
|
453
|
+
});
|
|
454
|
+
|
|
419
455
|
// POST /api/imap/sync (body: { accountId, force })
|
|
420
456
|
router.post('/api/imap/sync', async (req, res) => {
|
|
421
457
|
try {
|
|
@@ -57,9 +57,13 @@ function notifyIdle(accountId, folder) {
|
|
|
57
57
|
for (const h of idleHandlers) { try { h(accountId, folder); } catch {} }
|
|
58
58
|
}
|
|
59
59
|
|
|
60
|
-
function createImapClient(label, creds, accountId) {
|
|
60
|
+
function createImapClient(label, creds, accountId, secureOverride) {
|
|
61
61
|
const port = parseInt(creds.imap_port, 10) || 993;
|
|
62
|
-
|
|
62
|
+
// Default heuristic: 993/465 use implicit TLS, anything else uses STARTTLS.
|
|
63
|
+
// `secureOverride` lets the auto-fallback path force the opposite mode.
|
|
64
|
+
const isSecure = typeof secureOverride === 'boolean'
|
|
65
|
+
? secureOverride
|
|
66
|
+
: (port === 993 || port === 465);
|
|
63
67
|
const client = new ImapFlow({
|
|
64
68
|
host: creds.imap_host,
|
|
65
69
|
port,
|
|
@@ -68,6 +72,10 @@ function createImapClient(label, creds, accountId) {
|
|
|
68
72
|
logger: false,
|
|
69
73
|
clientInfo: { name: 'NHA-Mail', version: '1.0.0' },
|
|
70
74
|
emitLogs: false,
|
|
75
|
+
// Bounded timeouts so a misconfigured server doesn't hang the UI.
|
|
76
|
+
connectionTimeout: 15000,
|
|
77
|
+
greetingTimeout: 10000,
|
|
78
|
+
socketTimeout: 60000,
|
|
71
79
|
tls: { rejectUnauthorized: false }, // always set — safe for self-signed certs too
|
|
72
80
|
});
|
|
73
81
|
client.on('error', (err) => {
|
|
@@ -77,6 +85,12 @@ function createImapClient(label, creds, accountId) {
|
|
|
77
85
|
return client;
|
|
78
86
|
}
|
|
79
87
|
|
|
88
|
+
// Match the most common "wrong TLS mode" failure modes from ImapFlow.
|
|
89
|
+
function _looksLikeTlsMismatch(err) {
|
|
90
|
+
const msg = (err && err.message ? err.message : String(err)).toLowerCase();
|
|
91
|
+
return /greeting|tls|ssl|wrong version number|protocol|enotconn|econnreset|handshake/.test(msg);
|
|
92
|
+
}
|
|
93
|
+
|
|
80
94
|
export async function getImapClient(accountId) {
|
|
81
95
|
const existing = syncClients.get(accountId);
|
|
82
96
|
if (existing?.usable) return existing;
|
|
@@ -84,12 +98,66 @@ export async function getImapClient(accountId) {
|
|
|
84
98
|
|
|
85
99
|
const creds = getAccountCredentials(accountId);
|
|
86
100
|
if (!creds || !creds.imap_host) throw new Error(`No IMAP credentials for account ${accountId}`);
|
|
87
|
-
const
|
|
88
|
-
|
|
101
|
+
const label = `sync:${accountId.slice(0, 8)}`;
|
|
102
|
+
|
|
103
|
+
// First attempt with the heuristic-derived TLS mode.
|
|
104
|
+
let client = createImapClient(label, creds, accountId);
|
|
105
|
+
try {
|
|
106
|
+
await client.connect();
|
|
107
|
+
} catch (err) {
|
|
108
|
+
// Auto-fallback: if the failure smells like a TLS-mode mismatch, retry
|
|
109
|
+
// with the opposite mode before bubbling the error up to the user. The
|
|
110
|
+
// most common case is port 993 misclassified (or a non-standard port
|
|
111
|
+
// with implicit TLS) — the second try usually succeeds.
|
|
112
|
+
if (_looksLikeTlsMismatch(err)) {
|
|
113
|
+
try { await client.logout(); } catch {}
|
|
114
|
+
const port = parseInt(creds.imap_port, 10) || 993;
|
|
115
|
+
const fallbackSecure = !(port === 993 || port === 465);
|
|
116
|
+
console.warn(`[email:imap] ${label} TLS mismatch (${err.message}). Retrying with secure=${fallbackSecure}`);
|
|
117
|
+
client = createImapClient(label, creds, accountId, fallbackSecure);
|
|
118
|
+
await client.connect();
|
|
119
|
+
} else {
|
|
120
|
+
throw err;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
89
123
|
syncClients.set(accountId, client);
|
|
90
124
|
return client;
|
|
91
125
|
}
|
|
92
126
|
|
|
127
|
+
// Dry-run connectivity test used by Settings UI (no DB writes, no persistent
|
|
128
|
+
// client). Returns { ok, secure, message }.
|
|
129
|
+
export async function testImapConnection(creds) {
|
|
130
|
+
if (!creds?.imap_host) throw new Error('imap_host required');
|
|
131
|
+
if (!creds?.username || !creds?.password) throw new Error('Username and password required');
|
|
132
|
+
const port = parseInt(creds.imap_port, 10) || 993;
|
|
133
|
+
const tryConnect = async (secure) => {
|
|
134
|
+
const client = createImapClient('test', creds, null, secure);
|
|
135
|
+
try {
|
|
136
|
+
await client.connect();
|
|
137
|
+
const list = await client.list();
|
|
138
|
+
await client.logout();
|
|
139
|
+
return { ok: true, secure, folderCount: list.length };
|
|
140
|
+
} catch (err) {
|
|
141
|
+
try { await client.logout(); } catch {}
|
|
142
|
+
throw err;
|
|
143
|
+
}
|
|
144
|
+
};
|
|
145
|
+
const firstSecure = port === 993 || port === 465;
|
|
146
|
+
try {
|
|
147
|
+
return await tryConnect(firstSecure);
|
|
148
|
+
} catch (err) {
|
|
149
|
+
if (_looksLikeTlsMismatch(err)) {
|
|
150
|
+
try {
|
|
151
|
+
const r = await tryConnect(!firstSecure);
|
|
152
|
+
return { ...r, message: `Fallback TLS=${!firstSecure} (heuristic was wrong for this server)` };
|
|
153
|
+
} catch (err2) {
|
|
154
|
+
throw new Error(`IMAP test failed (both TLS modes): ${err.message} / ${err2.message}`);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
throw err;
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
93
161
|
export async function closeImapClient(accountId) {
|
|
94
162
|
const c = syncClients.get(accountId);
|
|
95
163
|
if (c) { try { await c.logout(); } catch {}; syncClients.delete(accountId); }
|
|
@@ -18,9 +18,9 @@ function threadId(messageId) {
|
|
|
18
18
|
return createHash('sha1').update(messageId).digest('hex');
|
|
19
19
|
}
|
|
20
20
|
|
|
21
|
-
function getTransporter(creds) {
|
|
21
|
+
function getTransporter(creds, secureOverride) {
|
|
22
22
|
const port = parseInt(creds.smtp_port, 10) || 587;
|
|
23
|
-
const isSecure = port === 465;
|
|
23
|
+
const isSecure = typeof secureOverride === 'boolean' ? secureOverride : (port === 465);
|
|
24
24
|
return createTransport({
|
|
25
25
|
host: creds.smtp_host,
|
|
26
26
|
port,
|
|
@@ -33,6 +33,53 @@ function getTransporter(creds) {
|
|
|
33
33
|
});
|
|
34
34
|
}
|
|
35
35
|
|
|
36
|
+
// Dry-run SMTP test used by Settings UI. Verifies auth, optionally sends a
|
|
37
|
+
// real "Hello from NHA" message to the configured address. Same TLS-mode
|
|
38
|
+
// auto-fallback pattern as IMAP.
|
|
39
|
+
export async function testSmtpConnection(creds, { sendTest = false } = {}) {
|
|
40
|
+
if (!creds?.smtp_host) throw new Error('smtp_host required');
|
|
41
|
+
if (!creds?.username || !creds?.password) throw new Error('Username and password required');
|
|
42
|
+
const port = parseInt(creds.smtp_port, 10) || 587;
|
|
43
|
+
const firstSecure = port === 465;
|
|
44
|
+
const trySend = async (secure) => {
|
|
45
|
+
const t = getTransporter(creds, secure);
|
|
46
|
+
try {
|
|
47
|
+
await t.verify();
|
|
48
|
+
let sendInfo = null;
|
|
49
|
+
if (sendTest) {
|
|
50
|
+
const to = creds.email_address || creds.username;
|
|
51
|
+
const from = creds.from_name ? `${creds.from_name} <${creds.email_address || creds.username}>` : (creds.email_address || creds.username);
|
|
52
|
+
sendInfo = await t.sendMail({
|
|
53
|
+
from,
|
|
54
|
+
to,
|
|
55
|
+
subject: 'NHA — Email di prova',
|
|
56
|
+
text: `Questo è un messaggio di prova inviato da NotHumanAllowed (${new Date().toISOString()}).\n\nSe lo vedi, la configurazione SMTP è corretta.`,
|
|
57
|
+
html: `<p>Questo è un messaggio di prova inviato da <strong>NotHumanAllowed</strong> (${new Date().toISOString()}).</p><p>Se lo vedi, la configurazione SMTP è corretta.</p>`,
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
try { t.close(); } catch {}
|
|
61
|
+
return { ok: true, secure, sent: !!sendInfo, messageId: sendInfo?.messageId || null };
|
|
62
|
+
} catch (err) {
|
|
63
|
+
try { t.close(); } catch {}
|
|
64
|
+
throw err;
|
|
65
|
+
}
|
|
66
|
+
};
|
|
67
|
+
try {
|
|
68
|
+
return await trySend(firstSecure);
|
|
69
|
+
} catch (err) {
|
|
70
|
+
const looksTls = /greeting|tls|ssl|wrong version number|protocol|handshake/i.test(err.message || '');
|
|
71
|
+
if (looksTls) {
|
|
72
|
+
try {
|
|
73
|
+
const r = await trySend(!firstSecure);
|
|
74
|
+
return { ...r, message: `Fallback TLS=${!firstSecure} (heuristic was wrong for this server)` };
|
|
75
|
+
} catch (err2) {
|
|
76
|
+
throw new Error(`SMTP test failed (both TLS modes): ${err.message} / ${err2.message}`);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
throw err;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
36
83
|
/**
|
|
37
84
|
* Send an email.
|
|
38
85
|
*
|
|
@@ -919,6 +919,50 @@ class TelegramResponder {
|
|
|
919
919
|
const shortConfirm = /^(s[ìi]\.?|ok\.?|okay\.?|yes\.?|yep\.?|sure\.?|certo\.?|procedi\.?|fallo\.?|vai\.?|go\.?|do it\.?|conferma\.?|certo che s[ìi]\.?|fallo pure\.?)$/i.test(cleanText.trim());
|
|
920
920
|
|
|
921
921
|
if (proposedAction && shortConfirm) {
|
|
922
|
+
// ── Server-side deterministic execution (15.1.43) ───────────────
|
|
923
|
+
// The previous "prompt-engineering" force retry kept failing when
|
|
924
|
+
// the LLM declared success without emitting the tool block. For
|
|
925
|
+
// the most common confirmed actions (DELETE, MOVE), we now bypass
|
|
926
|
+
// the LLM entirely: parse the proposal, resolve the eventId via
|
|
927
|
+
// calendar_date / calendar_find, then call calendar_delete /
|
|
928
|
+
// calendar_move directly. The user gets a guaranteed real result.
|
|
929
|
+
const directResult = await this._tryDirectAction(lastCtx.agentReply || '', this.config);
|
|
930
|
+
if (directResult) {
|
|
931
|
+
this.log(`[Telegram] ${fromUser}: direct-action ${directResult.action} → ${directResult.success ? 'OK' : 'FAIL'}`);
|
|
932
|
+
const personaName = this.config.responder?.telegram?.botName || this.config.responder?.botName || '';
|
|
933
|
+
const personaMode = this.config.responder?.telegram?.personaMode || (personaName ? 'persona' : 'agent');
|
|
934
|
+
let reply;
|
|
935
|
+
if (personaMode === 'persona-only' && personaName) {
|
|
936
|
+
reply = directResult.message;
|
|
937
|
+
} else if (personaMode === 'persona+role' && personaName) {
|
|
938
|
+
reply = `[${personaName} · herald]\n\n${directResult.message}`;
|
|
939
|
+
} else if (personaMode === 'persona' && personaName) {
|
|
940
|
+
reply = `[${personaName}]\n\n${directResult.message}`;
|
|
941
|
+
} else {
|
|
942
|
+
reply = `[HERALD]\n\n${directResult.message}`;
|
|
943
|
+
}
|
|
944
|
+
await this._telegramCall('sendMessage', { chat_id: chatId, text: reply });
|
|
945
|
+
|
|
946
|
+
// Update rolling memory + reset pending action (so a follow-up
|
|
947
|
+
// "Si" doesn't try to delete a second time).
|
|
948
|
+
const MAX = 20;
|
|
949
|
+
const prevLog = (lastCtx && Array.isArray(lastCtx.conversationLog)) ? lastCtx.conversationLog : [];
|
|
950
|
+
this._lastContextByChatId[chatId] = {
|
|
951
|
+
agent: 'herald',
|
|
952
|
+
userMsg: cleanText,
|
|
953
|
+
agentReply: directResult.message,
|
|
954
|
+
history: null,
|
|
955
|
+
conversationLog: [...prevLog,
|
|
956
|
+
{ role: 'user', content: cleanText, ts: Date.now() },
|
|
957
|
+
{ role: 'assistant', content: directResult.message, ts: Date.now() },
|
|
958
|
+
].slice(-MAX * 2),
|
|
959
|
+
ts: Date.now(),
|
|
960
|
+
};
|
|
961
|
+
this._lastAgentByChatId[chatId] = 'herald';
|
|
962
|
+
this._persistContext();
|
|
963
|
+
return;
|
|
964
|
+
}
|
|
965
|
+
|
|
922
966
|
// Extract the SPECIFIC action the assistant proposed (create/delete/
|
|
923
967
|
// update/move/send/...), so we can inject an explicit tool name in
|
|
924
968
|
// the instruction. Without this, Liara may pick a random tool —
|
|
@@ -1074,6 +1118,183 @@ class TelegramResponder {
|
|
|
1074
1118
|
}
|
|
1075
1119
|
}
|
|
1076
1120
|
|
|
1121
|
+
// ── Direct server-side execution of a confirmed pending action ────────
|
|
1122
|
+
// Returns { action, success, message } when it could resolve and execute
|
|
1123
|
+
// the action without involving the LLM. Returns null when the proposal
|
|
1124
|
+
// can't be reduced to deterministic parameters (e.g. ambiguous title,
|
|
1125
|
+
// multiple candidate events, unsupported action shape) — caller then
|
|
1126
|
+
// falls back to the prompt-engineering path.
|
|
1127
|
+
//
|
|
1128
|
+
// Currently supports: calendar_delete. (calendar_move/update can extend
|
|
1129
|
+
// the same pattern once the proposal language stabilizes.)
|
|
1130
|
+
async _tryDirectAction(proposalText, config) {
|
|
1131
|
+
if (!proposalText || typeof proposalText !== 'string') return null;
|
|
1132
|
+
const lower = proposalText.toLowerCase();
|
|
1133
|
+
|
|
1134
|
+
// Only handle DELETE for now — the riskiest "fake success" case.
|
|
1135
|
+
const isDelete = /\b(cancell|eliminar[eo]|rimuover|delete|cancel)\b/.test(lower);
|
|
1136
|
+
if (!isDelete) return null;
|
|
1137
|
+
|
|
1138
|
+
// Refuse if multiple distinct actions are proposed (chained plan).
|
|
1139
|
+
if (/(\bpoi\b|\binoltre\b|\bdopo\b|\bquindi\b).*(invier[oò]|crear[oò]|spostar[oò]|modificare[oò]|aggiunger[oò])/i.test(proposalText)) {
|
|
1140
|
+
return null;
|
|
1141
|
+
}
|
|
1142
|
+
|
|
1143
|
+
const extracted = this._extractCalendarProposal(proposalText);
|
|
1144
|
+
if (!extracted.date && !extracted.title) return null;
|
|
1145
|
+
|
|
1146
|
+
const { executeTool } = await import('./tool-executor.mjs');
|
|
1147
|
+
|
|
1148
|
+
// Candidates: events on the proposed date, or matching the title.
|
|
1149
|
+
let candidates = [];
|
|
1150
|
+
try {
|
|
1151
|
+
if (extracted.date) {
|
|
1152
|
+
const result = await executeTool('calendar_date', { date: extracted.date }, config);
|
|
1153
|
+
candidates = this._parseEventsFromToolOutput(result);
|
|
1154
|
+
}
|
|
1155
|
+
if (candidates.length === 0 && extracted.title) {
|
|
1156
|
+
const result = await executeTool('calendar_find', { query: extracted.title, daysAhead: 60 }, config);
|
|
1157
|
+
candidates = this._parseEventsFromToolOutput(result);
|
|
1158
|
+
}
|
|
1159
|
+
} catch (err) {
|
|
1160
|
+
this.log(`[Telegram] direct-action lookup failed: ${err.message}`);
|
|
1161
|
+
return null;
|
|
1162
|
+
}
|
|
1163
|
+
|
|
1164
|
+
if (candidates.length === 0) return null;
|
|
1165
|
+
|
|
1166
|
+
// Match by title tokens (case-insensitive, accent-insensitive).
|
|
1167
|
+
const norm = (s) => String(s || '')
|
|
1168
|
+
.toLowerCase()
|
|
1169
|
+
.normalize('NFD').replace(/[̀-ͯ]/g, '')
|
|
1170
|
+
.replace(/[^a-z0-9\s]/g, ' ')
|
|
1171
|
+
.split(/\s+/).filter(t => t.length > 2);
|
|
1172
|
+
|
|
1173
|
+
let match = null;
|
|
1174
|
+
if (extracted.title) {
|
|
1175
|
+
const titleTokens = norm(extracted.title);
|
|
1176
|
+
const scored = candidates.map(c => {
|
|
1177
|
+
const summaryTokens = new Set(norm(c.summary));
|
|
1178
|
+
const score = titleTokens.filter(t => summaryTokens.has(t)).length;
|
|
1179
|
+
return { c, score };
|
|
1180
|
+
}).sort((a, b) => b.score - a.score);
|
|
1181
|
+
const top = scored[0];
|
|
1182
|
+
if (top && top.score >= Math.max(1, Math.ceil(titleTokens.length * 0.5))) {
|
|
1183
|
+
// Reject if a tied second-best also matches as well — ambiguous.
|
|
1184
|
+
if (scored.length === 1 || scored[1].score < top.score) match = top.c;
|
|
1185
|
+
}
|
|
1186
|
+
}
|
|
1187
|
+
|
|
1188
|
+
// Fallback: if the date narrowed the day to a single event, that's
|
|
1189
|
+
// unambiguously the event the user meant.
|
|
1190
|
+
if (!match && candidates.length === 1 && extracted.date) match = candidates[0];
|
|
1191
|
+
|
|
1192
|
+
// Time hint as final tiebreaker.
|
|
1193
|
+
if (!match && extracted.time) {
|
|
1194
|
+
const exactTime = candidates.filter(c => (c.time || '').startsWith(extracted.time));
|
|
1195
|
+
if (exactTime.length === 1) match = exactTime[0];
|
|
1196
|
+
}
|
|
1197
|
+
|
|
1198
|
+
if (!match || !match.eventId) return null;
|
|
1199
|
+
|
|
1200
|
+
try {
|
|
1201
|
+
const delResult = await executeTool('calendar_delete', { eventId: match.eventId }, config);
|
|
1202
|
+
const ok = typeof delResult === 'string' && !/error|failed|could not|invalid|placeholder/i.test(delResult);
|
|
1203
|
+
const summary = match.summary || extracted.title || 'l\'appuntamento';
|
|
1204
|
+
const dateStr = extracted.date || (match.date || '');
|
|
1205
|
+
const message = ok
|
|
1206
|
+
? `Fatto. Ho cancellato "${summary}"${dateStr ? ` del ${this._formatDateIT(dateStr)}` : ''}${match.time ? ` alle ${match.time}` : ''}.`
|
|
1207
|
+
: `Non sono riuscito a cancellare l'evento: ${delResult}`;
|
|
1208
|
+
return { action: 'calendar_delete', success: ok, message };
|
|
1209
|
+
} catch (err) {
|
|
1210
|
+
return { action: 'calendar_delete', success: false, message: `Errore nella cancellazione: ${err.message}` };
|
|
1211
|
+
}
|
|
1212
|
+
}
|
|
1213
|
+
|
|
1214
|
+
// Extract { title, date (YYYY-MM-DD), time (HH:MM) } from a free-form
|
|
1215
|
+
// Italian/English proposal sentence. Best-effort; returns empty fields
|
|
1216
|
+
// when nothing parseable was found.
|
|
1217
|
+
_extractCalendarProposal(text) {
|
|
1218
|
+
const out = { title: '', date: '', time: '' };
|
|
1219
|
+
|
|
1220
|
+
// Title: prefer quoted text ("..." or «...»).
|
|
1221
|
+
const q = text.match(/["«""]([^"«»""\n]{2,80})["»""]/);
|
|
1222
|
+
if (q) out.title = q[1].trim();
|
|
1223
|
+
|
|
1224
|
+
// Time: HH:MM (24h) or "alle 18" / "ore 18"
|
|
1225
|
+
const tm = text.match(/\b([01]?\d|2[0-3]):([0-5]\d)\b/);
|
|
1226
|
+
if (tm) out.time = `${tm[1].padStart(2, '0')}:${tm[2]}`;
|
|
1227
|
+
else {
|
|
1228
|
+
const hourOnly = text.match(/\b(?:alle|ore|at)\s+(\d{1,2})(?!\d)\b/i);
|
|
1229
|
+
if (hourOnly) out.time = `${hourOnly[1].padStart(2, '0')}:00`;
|
|
1230
|
+
}
|
|
1231
|
+
|
|
1232
|
+
// Date: italian "15 maggio [2026]", numeric "15/05[/2026]", ISO 2026-05-15
|
|
1233
|
+
const iso = text.match(/\b(20\d{2})-(\d{2})-(\d{2})\b/);
|
|
1234
|
+
if (iso) {
|
|
1235
|
+
out.date = `${iso[1]}-${iso[2]}-${iso[3]}`;
|
|
1236
|
+
} else {
|
|
1237
|
+
const numeric = text.match(/\b(\d{1,2})[\/\-](\d{1,2})(?:[\/\-](20\d{2}))?\b/);
|
|
1238
|
+
if (numeric) {
|
|
1239
|
+
const yr = numeric[3] || String(new Date().getFullYear());
|
|
1240
|
+
out.date = `${yr}-${numeric[2].padStart(2, '0')}-${numeric[1].padStart(2, '0')}`;
|
|
1241
|
+
} else {
|
|
1242
|
+
const MONTHS_IT = {
|
|
1243
|
+
gennaio:'01', febbraio:'02', marzo:'03', aprile:'04', maggio:'05', giugno:'06',
|
|
1244
|
+
luglio:'07', agosto:'08', settembre:'09', ottobre:'10', novembre:'11', dicembre:'12',
|
|
1245
|
+
};
|
|
1246
|
+
const MONTHS_EN = {
|
|
1247
|
+
january:'01', february:'02', march:'03', april:'04', may:'05', june:'06',
|
|
1248
|
+
july:'07', august:'08', september:'09', october:'10', november:'11', december:'12',
|
|
1249
|
+
jan:'01', feb:'02', mar:'03', apr:'04', jun:'06', jul:'07', aug:'08', sep:'09',
|
|
1250
|
+
sept:'09', oct:'10', nov:'11', dec:'12',
|
|
1251
|
+
};
|
|
1252
|
+
const all = { ...MONTHS_IT, ...MONTHS_EN };
|
|
1253
|
+
const monthRe = new RegExp(`\\b(\\d{1,2})\\s+(${Object.keys(all).join('|')})(?:\\s+(20\\d{2}))?\\b`, 'i');
|
|
1254
|
+
const monthM = text.match(monthRe);
|
|
1255
|
+
if (monthM) {
|
|
1256
|
+
const yr = monthM[3] || String(new Date().getFullYear());
|
|
1257
|
+
out.date = `${yr}-${all[monthM[2].toLowerCase()]}-${monthM[1].padStart(2, '0')}`;
|
|
1258
|
+
}
|
|
1259
|
+
}
|
|
1260
|
+
}
|
|
1261
|
+
|
|
1262
|
+
return out;
|
|
1263
|
+
}
|
|
1264
|
+
|
|
1265
|
+
// Parse calendar_date / calendar_find tool output. The executor returns
|
|
1266
|
+
// a human-readable string with each event on its own line plus the
|
|
1267
|
+
// eventId in parentheses. We extract structured records.
|
|
1268
|
+
_parseEventsFromToolOutput(toolResult) {
|
|
1269
|
+
const text = typeof toolResult === 'string' ? toolResult : JSON.stringify(toolResult || '');
|
|
1270
|
+
const lines = text.split(/\r?\n/);
|
|
1271
|
+
const events = [];
|
|
1272
|
+
for (const line of lines) {
|
|
1273
|
+
// Look for the eventId pattern: parenthesised long alphanumeric.
|
|
1274
|
+
const idMatch = line.match(/\(([a-z0-9_\-]{8,})\)/i);
|
|
1275
|
+
if (!idMatch) continue;
|
|
1276
|
+
const eventId = idMatch[1];
|
|
1277
|
+
// Strip the (id) and any leading bullets/dashes/times for the summary.
|
|
1278
|
+
const cleaned = line.replace(/\([a-z0-9_\-]{8,}\)/i, '').trim();
|
|
1279
|
+
const timeM = cleaned.match(/\b([01]?\d|2[0-3]):([0-5]\d)\b/);
|
|
1280
|
+
const time = timeM ? `${timeM[1].padStart(2, '0')}:${timeM[2]}` : '';
|
|
1281
|
+
// Heuristic summary: text after the time, before any " - " or " · ".
|
|
1282
|
+
let summary = cleaned;
|
|
1283
|
+
if (timeM) summary = cleaned.slice(cleaned.indexOf(timeM[0]) + timeM[0].length);
|
|
1284
|
+
summary = summary.replace(/^[\s\-–·•\.]+/, '').replace(/[\s\-–·•\.]+$/, '').trim();
|
|
1285
|
+
if (!summary) summary = cleaned.replace(/^[\s\-–·•\.\d:]+/, '').trim();
|
|
1286
|
+
events.push({ eventId, summary, time, date: '' });
|
|
1287
|
+
}
|
|
1288
|
+
return events;
|
|
1289
|
+
}
|
|
1290
|
+
|
|
1291
|
+
_formatDateIT(isoDate) {
|
|
1292
|
+
const m = String(isoDate || '').match(/^(\d{4})-(\d{2})-(\d{2})$/);
|
|
1293
|
+
if (!m) return isoDate;
|
|
1294
|
+
const months = ['gennaio','febbraio','marzo','aprile','maggio','giugno','luglio','agosto','settembre','ottobre','novembre','dicembre'];
|
|
1295
|
+
return `${parseInt(m[3], 10)} ${months[parseInt(m[2], 10) - 1]} ${m[1]}`;
|
|
1296
|
+
}
|
|
1297
|
+
|
|
1077
1298
|
async _telegramCall(method, body) {
|
|
1078
1299
|
const res = await fetch(`https://api.telegram.org/bot${this.token}/${method}`, {
|
|
1079
1300
|
method: 'POST',
|