loopengine 0.3.0 → 0.3.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/core/agent-config.d.ts +2 -0
- package/dist/core/http-notifier.js +14 -3
- package/dist/core/http-notify-triggers/telegram.d.ts +20 -1
- package/dist/core/http-notify-triggers/telegram.js +63 -17
- package/dist/core/messaging/connector.d.ts +7 -0
- package/dist/core/messaging/connector.js +1 -1
- package/dist/core/messaging/connectors/slack/events.d.ts +2 -1
- package/dist/core/messaging/connectors/slack/events.js +6 -2
- package/dist/core/messaging/connectors/slack/setup.d.ts +2 -1
- package/dist/core/messaging/connectors/slack/setup.js +10 -5
- package/dist/core/messaging/connectors/telegram/format.d.ts +6 -0
- package/dist/core/messaging/connectors/telegram/format.js +57 -0
- package/dist/core/messaging/connectors/telegram/index.d.ts +39 -0
- package/dist/core/messaging/connectors/telegram/index.js +136 -0
- package/dist/core/messaging/connectors/telegram/store.d.ts +31 -0
- package/dist/core/messaging/connectors/telegram/store.js +69 -0
- package/dist/core/messaging/connectors/telegram/updates.d.ts +40 -0
- package/dist/core/messaging/connectors/telegram/updates.js +112 -0
- package/dist/core/messaging/jobs.d.ts +44 -0
- package/dist/core/messaging/jobs.js +100 -0
- package/dist/core/messaging/threads.d.ts +12 -0
- package/dist/core/messaging/threads.js +36 -0
- package/dist/core/notifier-file.d.ts +13 -5
- package/dist/core/notifier-file.js +41 -6
- package/dist/core/run-callbacks.d.ts +39 -0
- package/dist/core/run-callbacks.js +125 -0
- package/dist/core/system-agents/workflow-planner.d.ts +37 -4
- package/dist/core/system-agents/workflow-planner.js +79 -5
- package/dist/core/workflow-runs.d.ts +131 -0
- package/dist/core/workflow-runs.js +229 -0
- package/dist/core/workflow-triggers.d.ts +140 -0
- package/dist/core/workflow-triggers.js +378 -0
- package/dist/web/agents-config-page.js +168 -50
- package/dist/web/notifier-admin.d.ts +69 -13
- package/dist/web/notifier-admin.js +127 -31
- package/dist/web/workflow-admin.d.ts +10 -0
- package/dist/web/workflow-admin.js +42 -3
- package/dist/web/workflow-page.js +93 -2
- package/package.json +7 -1
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
// What Telegram won't tell us later: the Bot API has no way to read a
|
|
2
|
+
// chat's history, so the messages in the agent's threads are kept here as
|
|
3
|
+
// they pass through, in agents/<name>/.messaging/telegram.jsonl, along
|
|
4
|
+
// with the people seen (for names in replies and the approver picker).
|
|
5
|
+
//
|
|
6
|
+
// Telegram has no threads either. A "thread" here is a chain of replies:
|
|
7
|
+
// its first message's id is the thread id, and each later message that
|
|
8
|
+
// replies into it (a person's reply, or the agent's) is recorded with
|
|
9
|
+
// that root, so a reply to any message in the chain finds its thread.
|
|
10
|
+
import { appendFileSync, existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from 'node:fs';
|
|
11
|
+
import { join } from 'node:path';
|
|
12
|
+
// Past this, the file is cut to its newer half on the next write.
|
|
13
|
+
const MAX_BYTES = 4 * 1024 * 1024;
|
|
14
|
+
function storeFile(agentDir) {
|
|
15
|
+
return join(agentDir, '.messaging', 'telegram.jsonl');
|
|
16
|
+
}
|
|
17
|
+
function readEntries(agentDir) {
|
|
18
|
+
const path = storeFile(agentDir);
|
|
19
|
+
if (!existsSync(path))
|
|
20
|
+
return [];
|
|
21
|
+
const entries = [];
|
|
22
|
+
for (const line of readFileSync(path, 'utf8').split('\n')) {
|
|
23
|
+
if (!line)
|
|
24
|
+
continue;
|
|
25
|
+
try {
|
|
26
|
+
entries.push(JSON.parse(line));
|
|
27
|
+
}
|
|
28
|
+
catch { }
|
|
29
|
+
}
|
|
30
|
+
return entries;
|
|
31
|
+
}
|
|
32
|
+
/** Never throws: a message that can't be kept only loses its context. */
|
|
33
|
+
export function recordTelegramMessage(agentDir, entry) {
|
|
34
|
+
try {
|
|
35
|
+
mkdirSync(join(agentDir, '.messaging'), { recursive: true });
|
|
36
|
+
const path = storeFile(agentDir);
|
|
37
|
+
if (existsSync(path) && statSync(path).size > MAX_BYTES) {
|
|
38
|
+
const lines = readFileSync(path, 'utf8').split('\n').filter(Boolean);
|
|
39
|
+
writeFileSync(path, lines.slice(Math.floor(lines.length / 2)).join('\n') + '\n');
|
|
40
|
+
}
|
|
41
|
+
appendFileSync(path, JSON.stringify({ ...entry, text: entry.text.slice(0, 4000), at: new Date().toISOString() }) + '\n');
|
|
42
|
+
}
|
|
43
|
+
catch (err) {
|
|
44
|
+
console.error('[loopengine] could not keep the Telegram message:', err instanceof Error ? err.message : err);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
/** The thread a message belongs to: its recorded root, else the message
|
|
48
|
+
* itself. */
|
|
49
|
+
export function telegramRootOf(agentDir, chat, id) {
|
|
50
|
+
return readEntries(agentDir).find((e) => e.chat === chat && e.id === id)?.root ?? id;
|
|
51
|
+
}
|
|
52
|
+
/** A thread's messages, oldest first, at most the latest 100. */
|
|
53
|
+
export function readTelegramThread(agentDir, chat, root) {
|
|
54
|
+
return readEntries(agentDir)
|
|
55
|
+
.filter((e) => e.chat === chat && e.root === root)
|
|
56
|
+
.slice(-100)
|
|
57
|
+
.map((e) => ({ ...(e.user ? { user: e.user } : {}), fromBot: e.fromBot, text: e.text, ts: e.id }));
|
|
58
|
+
}
|
|
59
|
+
/** Everyone who has written to the agent, newest first, optionally in
|
|
60
|
+
* one chat only. */
|
|
61
|
+
export function telegramPeople(agentDir, chat) {
|
|
62
|
+
const people = new Map();
|
|
63
|
+
for (const e of readEntries(agentDir).reverse()) {
|
|
64
|
+
if (!e.user || people.has(e.user) || (chat && e.chat !== chat))
|
|
65
|
+
continue;
|
|
66
|
+
people.set(e.user, { id: e.user, name: e.name || (e.username ? `@${e.username}` : e.user), ...(e.username ? { username: e.username } : {}) });
|
|
67
|
+
}
|
|
68
|
+
return [...people.values()];
|
|
69
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import type { InboundMessage, MessagingAction } from '../../connector.js';
|
|
2
|
+
export declare function verifyTelegramRequest(header: string | undefined, secret: string): boolean;
|
|
3
|
+
export type TelegramButton = {
|
|
4
|
+
type: 'decide';
|
|
5
|
+
decision: 'approve' | 'deny';
|
|
6
|
+
} | {
|
|
7
|
+
type: 'answer_option';
|
|
8
|
+
index: number;
|
|
9
|
+
} | {
|
|
10
|
+
type: 'action';
|
|
11
|
+
action: Exclude<MessagingAction, 'deny'>;
|
|
12
|
+
};
|
|
13
|
+
/** The callback_data for a button. */
|
|
14
|
+
export declare function telegramCallbackData(button: TelegramButton, pendingId: string): string;
|
|
15
|
+
export type TelegramUpdate = ({
|
|
16
|
+
type: 'message';
|
|
17
|
+
username?: string;
|
|
18
|
+
} & InboundMessage) | {
|
|
19
|
+
type: 'click';
|
|
20
|
+
/** answerCallbackQuery takes it, to stop the button's spinner. */
|
|
21
|
+
callbackId: string;
|
|
22
|
+
conversation: string;
|
|
23
|
+
/** The message the button is on. */
|
|
24
|
+
messageId: string;
|
|
25
|
+
user: string;
|
|
26
|
+
userName?: string;
|
|
27
|
+
username?: string;
|
|
28
|
+
button: TelegramButton;
|
|
29
|
+
pendingId: string;
|
|
30
|
+
} | {
|
|
31
|
+
type: 'ignore';
|
|
32
|
+
};
|
|
33
|
+
/** Reads an Update. Only people's new text messages and clicks on the
|
|
34
|
+
* agent's buttons come through. `botUsername` (without @) is how a
|
|
35
|
+
* mention is spotted; `rootOf` finds the thread a replied-to message is
|
|
36
|
+
* in (./store.ts). */
|
|
37
|
+
export declare function parseTelegramUpdate(rawBody: string, options: {
|
|
38
|
+
botUsername?: string;
|
|
39
|
+
rootOf: (chat: string, id: string) => string;
|
|
40
|
+
}): TelegramUpdate;
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
// Reading what Telegram sends to POST /hooks/telegram/<agent>: checking
|
|
2
|
+
// it came from Telegram, and turning an Update into a person's message or
|
|
3
|
+
// a button click.
|
|
4
|
+
//
|
|
5
|
+
// Telegram signs nothing; instead setWebhook is given a secret_token that
|
|
6
|
+
// Telegram sends back in X-Telegram-Bot-Api-Secret-Token with every
|
|
7
|
+
// update (./setup.ts makes one per agent).
|
|
8
|
+
//
|
|
9
|
+
// Buttons carry `<code>:<pendingId>` as callback_data, which Telegram
|
|
10
|
+
// caps at 64 bytes (a pendingId is a 36-character UUID): a/d for a
|
|
11
|
+
// request's Approve/Deny, q<n> for a question's n-th option, and the
|
|
12
|
+
// engine's own thread buttons (core/messaging/engine.ts).
|
|
13
|
+
import { timingSafeEqual } from 'node:crypto';
|
|
14
|
+
export function verifyTelegramRequest(header, secret) {
|
|
15
|
+
if (!header)
|
|
16
|
+
return false;
|
|
17
|
+
const expected = Buffer.from(secret);
|
|
18
|
+
const actual = Buffer.from(header);
|
|
19
|
+
return expected.length === actual.length && timingSafeEqual(expected, actual);
|
|
20
|
+
}
|
|
21
|
+
const ACTION_CODES = {
|
|
22
|
+
approve_edited: 'ae',
|
|
23
|
+
keep_original: 'ko',
|
|
24
|
+
confirm: 'c',
|
|
25
|
+
cancel_confirm: 'cc',
|
|
26
|
+
};
|
|
27
|
+
/** The callback_data for a button. */
|
|
28
|
+
export function telegramCallbackData(button, pendingId) {
|
|
29
|
+
const code = button.type === 'decide' ? (button.decision === 'approve' ? 'a' : 'd') : button.type === 'answer_option' ? `q${button.index}` : ACTION_CODES[button.action];
|
|
30
|
+
return `${code}:${pendingId}`;
|
|
31
|
+
}
|
|
32
|
+
function readCallbackData(data) {
|
|
33
|
+
const match = /^([a-z]+)(\d*):(.+)$/.exec(data);
|
|
34
|
+
if (!match)
|
|
35
|
+
return undefined;
|
|
36
|
+
const [, code, index, pendingId] = match;
|
|
37
|
+
if (code === 'a' && !index)
|
|
38
|
+
return { button: { type: 'decide', decision: 'approve' }, pendingId };
|
|
39
|
+
if (code === 'd' && !index)
|
|
40
|
+
return { button: { type: 'decide', decision: 'deny' }, pendingId };
|
|
41
|
+
if (code === 'q' && index)
|
|
42
|
+
return { button: { type: 'answer_option', index: Number(index) }, pendingId };
|
|
43
|
+
const action = Object.keys(ACTION_CODES).find((a) => ACTION_CODES[a] === code);
|
|
44
|
+
return action && !index ? { button: { type: 'action', action }, pendingId } : undefined;
|
|
45
|
+
}
|
|
46
|
+
const nameOf = (u) => [u.first_name, u.last_name].filter(Boolean).join(' ') || undefined;
|
|
47
|
+
/** Reads an Update. Only people's new text messages and clicks on the
|
|
48
|
+
* agent's buttons come through. `botUsername` (without @) is how a
|
|
49
|
+
* mention is spotted; `rootOf` finds the thread a replied-to message is
|
|
50
|
+
* in (./store.ts). */
|
|
51
|
+
export function parseTelegramUpdate(rawBody, options) {
|
|
52
|
+
let update;
|
|
53
|
+
try {
|
|
54
|
+
update = JSON.parse(rawBody);
|
|
55
|
+
}
|
|
56
|
+
catch {
|
|
57
|
+
return { type: 'ignore' };
|
|
58
|
+
}
|
|
59
|
+
const click = update.callback_query;
|
|
60
|
+
if (click) {
|
|
61
|
+
const parsed = click.data ? readCallbackData(click.data) : undefined;
|
|
62
|
+
if (!parsed || !click.id || !click.from?.id || click.message?.message_id === undefined || click.message.chat?.id === undefined)
|
|
63
|
+
return { type: 'ignore' };
|
|
64
|
+
return {
|
|
65
|
+
type: 'click',
|
|
66
|
+
callbackId: click.id,
|
|
67
|
+
conversation: String(click.message.chat.id),
|
|
68
|
+
messageId: String(click.message.message_id),
|
|
69
|
+
user: String(click.from.id),
|
|
70
|
+
...(nameOf(click.from) ? { userName: nameOf(click.from) } : {}),
|
|
71
|
+
...(click.from.username ? { username: click.from.username } : {}),
|
|
72
|
+
...parsed,
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
const message = update.message;
|
|
76
|
+
if (update.update_id === undefined || !message || message.message_id === undefined || message.chat?.id === undefined)
|
|
77
|
+
return { type: 'ignore' };
|
|
78
|
+
if (!message.from?.id || message.from.is_bot || typeof message.text !== 'string')
|
|
79
|
+
return { type: 'ignore' };
|
|
80
|
+
const chat = String(message.chat.id);
|
|
81
|
+
const bot = options.botUsername?.toLowerCase();
|
|
82
|
+
let text = message.text;
|
|
83
|
+
let mentioned = false;
|
|
84
|
+
if (bot) {
|
|
85
|
+
// @bot as a mention entity, or a command addressed to it (/do@bot).
|
|
86
|
+
for (const entity of message.entities ?? []) {
|
|
87
|
+
const part = text.slice(entity.offset ?? 0, (entity.offset ?? 0) + (entity.length ?? 0)).toLowerCase();
|
|
88
|
+
if ((entity.type === 'mention' && part === `@${bot}`) || (entity.type === 'bot_command' && part.endsWith(`@${bot}`)))
|
|
89
|
+
mentioned = true;
|
|
90
|
+
}
|
|
91
|
+
if (mentioned)
|
|
92
|
+
text = text.replace(new RegExp(`@${bot}\\b`, 'ig'), '').trim();
|
|
93
|
+
}
|
|
94
|
+
// Opening a chat with a bot sends /start; it isn't a job.
|
|
95
|
+
if (/^\/start(\s|$)/.test(text))
|
|
96
|
+
text = text.replace(/^\/start\s*/, '');
|
|
97
|
+
const replyTo = message.reply_to_message?.message_id;
|
|
98
|
+
return {
|
|
99
|
+
type: 'message',
|
|
100
|
+
app: 'telegram',
|
|
101
|
+
eventId: String(update.update_id),
|
|
102
|
+
conversation: chat,
|
|
103
|
+
user: String(message.from.id),
|
|
104
|
+
text,
|
|
105
|
+
ts: String(message.message_id),
|
|
106
|
+
...(replyTo !== undefined ? { threadTs: options.rootOf(chat, String(replyTo)) } : {}),
|
|
107
|
+
...(message.chat.type === 'private' ? { direct: true } : {}),
|
|
108
|
+
...(mentioned ? { mentioned: true } : {}),
|
|
109
|
+
...(nameOf(message.from) ? { userName: nameOf(message.from) } : {}),
|
|
110
|
+
...(message.from.username ? { username: message.from.username } : {}),
|
|
111
|
+
};
|
|
112
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import type { InboundMessage, MessagingConnector } from './connector.js';
|
|
2
|
+
import { type JobThread } from './threads.js';
|
|
3
|
+
export type JobTurn = {
|
|
4
|
+
kind: 'done';
|
|
5
|
+
text: string;
|
|
6
|
+
} | {
|
|
7
|
+
kind: 'pending';
|
|
8
|
+
items: Array<{
|
|
9
|
+
pendingId: string;
|
|
10
|
+
kind: 'approval' | 'question';
|
|
11
|
+
tool: string;
|
|
12
|
+
question?: string;
|
|
13
|
+
}>;
|
|
14
|
+
};
|
|
15
|
+
export interface JobDeps {
|
|
16
|
+
agentName: string;
|
|
17
|
+
agentDir: string;
|
|
18
|
+
connector: MessagingConnector;
|
|
19
|
+
plan?: string;
|
|
20
|
+
/** Whether this person may start a job, or continue one, here. */
|
|
21
|
+
allowed(message: InboundMessage): {
|
|
22
|
+
allowed: boolean;
|
|
23
|
+
message?: string;
|
|
24
|
+
};
|
|
25
|
+
/** One agent turn in `sessionId` with the person's message. */
|
|
26
|
+
runTurn(sessionId: string, text: string): Promise<JobTurn>;
|
|
27
|
+
/** The decision page for a pending item, when the server's address is known. */
|
|
28
|
+
decideLink?(pendingId: string, kind: 'approval' | 'question'): string | undefined;
|
|
29
|
+
}
|
|
30
|
+
/** A session id for a job thread, from the app's own ids: one per thread
|
|
31
|
+
* in a channel, one per DM (so a DM keeps its context from one message to
|
|
32
|
+
* the next). Only characters a session id can hold. */
|
|
33
|
+
export declare function jobSessionId(message: Pick<InboundMessage, 'app' | 'conversation' | 'direct'>, threadTs: string): string;
|
|
34
|
+
/** The job thread a message continues, if any: the thread it's in, or,
|
|
35
|
+
* in a DM, the DM itself. */
|
|
36
|
+
export declare function findJobFor(agentDir: string, message: InboundMessage): JobThread | undefined;
|
|
37
|
+
/** Whether a message starts or continues a job: a DM, a mention, or any
|
|
38
|
+
* message in a job's thread. */
|
|
39
|
+
export declare function isJobMessage(agentDir: string, message: InboundMessage): boolean;
|
|
40
|
+
/** Runs the agent on a job message and answers in its thread. */
|
|
41
|
+
export declare function handleJobMessage(message: InboundMessage, deps: JobDeps): Promise<void>;
|
|
42
|
+
/** Posts a resumed run's outcome in the job thread it came from, if it
|
|
43
|
+
* came from one. */
|
|
44
|
+
export declare function settleJobThread(deps: Pick<JobDeps, 'agentDir' | 'connector' | 'decideLink'>, sessionId: string, turn: JobTurn): Promise<void>;
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { findJobThread, findJobThreadBySession, recordJobThread, recordThread } from './threads.js';
|
|
2
|
+
/** A session id for a job thread, from the app's own ids: one per thread
|
|
3
|
+
* in a channel, one per DM (so a DM keeps its context from one message to
|
|
4
|
+
* the next). Only characters a session id can hold. */
|
|
5
|
+
export function jobSessionId(message, threadTs) {
|
|
6
|
+
const safe = (s) => s.replace(/[^A-Za-z0-9_-]/g, '_');
|
|
7
|
+
return message.direct ? `${message.app}-dm-${safe(message.conversation)}` : `${message.app}-${safe(message.conversation)}-${safe(threadTs)}`;
|
|
8
|
+
}
|
|
9
|
+
/** The job thread a message continues, if any: the thread it's in, or,
|
|
10
|
+
* in a DM, the DM itself. */
|
|
11
|
+
export function findJobFor(agentDir, message) {
|
|
12
|
+
const threadTs = message.threadTs ?? message.ts;
|
|
13
|
+
const inThread = findJobThread(agentDir, message.app, message.conversation, threadTs);
|
|
14
|
+
if (inThread)
|
|
15
|
+
return inThread;
|
|
16
|
+
if (!message.direct)
|
|
17
|
+
return undefined;
|
|
18
|
+
const sessionId = jobSessionId(message, threadTs);
|
|
19
|
+
return findJobThreadBySession(agentDir, sessionId);
|
|
20
|
+
}
|
|
21
|
+
/** Whether a message starts or continues a job: a DM, a mention, or any
|
|
22
|
+
* message in a job's thread. */
|
|
23
|
+
export function isJobMessage(agentDir, message) {
|
|
24
|
+
return Boolean(message.direct || message.mentioned || findJobFor(agentDir, message));
|
|
25
|
+
}
|
|
26
|
+
// One turn at a time per session: a second message waits for the first's
|
|
27
|
+
// answer rather than racing it.
|
|
28
|
+
const sessionLocks = new Map();
|
|
29
|
+
function inSession(key, fn) {
|
|
30
|
+
const previous = sessionLocks.get(key) ?? Promise.resolve();
|
|
31
|
+
const next = previous.then(fn, fn);
|
|
32
|
+
const settled = next.catch(() => { });
|
|
33
|
+
sessionLocks.set(key, settled);
|
|
34
|
+
void settled.then(() => {
|
|
35
|
+
if (sessionLocks.get(key) === settled)
|
|
36
|
+
sessionLocks.delete(key);
|
|
37
|
+
});
|
|
38
|
+
return next;
|
|
39
|
+
}
|
|
40
|
+
/** Runs the agent on a job message and answers in its thread. */
|
|
41
|
+
export async function handleJobMessage(message, deps) {
|
|
42
|
+
const { connector } = deps;
|
|
43
|
+
const threadTs = message.threadTs ?? message.ts;
|
|
44
|
+
const say = (text) => connector.postInThread(message.conversation, threadTs, text);
|
|
45
|
+
if (deps.plan === 'free') {
|
|
46
|
+
await say(`Starting jobs from ${connector.name} needs LoopEngine Pro.`);
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
const gate = deps.allowed(message);
|
|
50
|
+
if (!gate.allowed) {
|
|
51
|
+
await say(gate.message ?? `You can't start jobs for ${deps.agentName} here.`);
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
const text = message.text.trim();
|
|
55
|
+
if (!text) {
|
|
56
|
+
await say('What should I do? Write the job in the same message.');
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
const sessionId = findJobFor(deps.agentDir, message)?.sessionId ?? jobSessionId(message, threadTs);
|
|
60
|
+
// Recorded per message, so a resumed run answers in the thread of the
|
|
61
|
+
// message that started it.
|
|
62
|
+
recordJobThread(deps.agentDir, { app: message.app, conversation: message.conversation, ts: threadTs, sessionId });
|
|
63
|
+
await inSession(`${deps.agentName}:${sessionId}`, async () => {
|
|
64
|
+
let turn;
|
|
65
|
+
try {
|
|
66
|
+
turn = await deps.runTurn(sessionId, text);
|
|
67
|
+
}
|
|
68
|
+
catch (err) {
|
|
69
|
+
await say(`That didn't work: ${connector.escape(err instanceof Error ? err.message : String(err))}`);
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
await postTurn(deps, { app: message.app, conversation: message.conversation, ts: threadTs, sessionId }, turn);
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
async function postTurn(deps, job, turn) {
|
|
76
|
+
const say = (text) => deps.connector.postInThread(job.conversation, job.ts, text);
|
|
77
|
+
if (turn.kind === 'done') {
|
|
78
|
+
await say(turn.text.trim() || 'Done.');
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
for (const item of turn.items) {
|
|
82
|
+
// The thread is linked to the request, so a reply here can ask about
|
|
83
|
+
// it or decide it (core/messaging/engine.ts).
|
|
84
|
+
recordThread(deps.agentDir, { app: job.app, conversation: job.conversation, ts: job.ts, pendingId: item.pendingId, kind: item.kind });
|
|
85
|
+
const link = deps.decideLink?.(item.pendingId, item.kind);
|
|
86
|
+
const head = item.kind === 'approval'
|
|
87
|
+
? `This needs approval before it runs \`${item.tool}\`.`
|
|
88
|
+
: `A question before I go on: ${deps.connector.escape(item.question ?? '')}`;
|
|
89
|
+
const how = item.kind === 'approval' ? 'An approver can reply here to approve or deny it' : 'Reply here to answer it';
|
|
90
|
+
await say(`${head}\n${how}${link ? `, or [open the request](${link})` : ''}. I'll carry on in this thread once it's decided.`);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
/** Posts a resumed run's outcome in the job thread it came from, if it
|
|
94
|
+
* came from one. */
|
|
95
|
+
export async function settleJobThread(deps, sessionId, turn) {
|
|
96
|
+
const job = findJobThreadBySession(deps.agentDir, sessionId);
|
|
97
|
+
if (!job || job.app !== deps.connector.app)
|
|
98
|
+
return;
|
|
99
|
+
await postTurn(deps, job, turn);
|
|
100
|
+
}
|
|
@@ -22,3 +22,15 @@ export declare function recordThread(agentDir: string, link: ThreadLink): void;
|
|
|
22
22
|
export declare function findThreadByPending(agentDir: string, pendingId: string): ThreadLink | undefined;
|
|
23
23
|
/** The request a thread belongs to, or undefined for any other thread. */
|
|
24
24
|
export declare function findThread(agentDir: string, app: string, conversation: string, ts: string): ThreadLink | undefined;
|
|
25
|
+
export interface JobThread {
|
|
26
|
+
app: string;
|
|
27
|
+
conversation: string;
|
|
28
|
+
/** The thread's first message id. */
|
|
29
|
+
ts: string;
|
|
30
|
+
sessionId: string;
|
|
31
|
+
}
|
|
32
|
+
/** Never throws, like recordThread. */
|
|
33
|
+
export declare function recordJobThread(agentDir: string, job: JobThread): void;
|
|
34
|
+
export declare function findJobThread(agentDir: string, app: string, conversation: string, ts: string): JobThread | undefined;
|
|
35
|
+
/** The session's latest job thread: where its last message came from. */
|
|
36
|
+
export declare function findJobThreadBySession(agentDir: string, sessionId: string): JobThread | undefined;
|
|
@@ -42,3 +42,39 @@ function readLinks(agentDir) {
|
|
|
42
42
|
export function findThread(agentDir, app, conversation, ts) {
|
|
43
43
|
return readLinks(agentDir).filter((link) => link.app === app && link.conversation === conversation && link.ts === ts).at(-1);
|
|
44
44
|
}
|
|
45
|
+
function jobsFile(agentDir) {
|
|
46
|
+
return join(agentDir, '.messaging', 'jobs.jsonl');
|
|
47
|
+
}
|
|
48
|
+
function readJobs(agentDir) {
|
|
49
|
+
const path = jobsFile(agentDir);
|
|
50
|
+
if (!existsSync(path))
|
|
51
|
+
return [];
|
|
52
|
+
const jobs = [];
|
|
53
|
+
for (const line of readFileSync(path, 'utf8').split('\n')) {
|
|
54
|
+
if (!line)
|
|
55
|
+
continue;
|
|
56
|
+
try {
|
|
57
|
+
jobs.push(JSON.parse(line));
|
|
58
|
+
}
|
|
59
|
+
catch { }
|
|
60
|
+
}
|
|
61
|
+
return jobs;
|
|
62
|
+
}
|
|
63
|
+
/** Never throws, like recordThread. */
|
|
64
|
+
export function recordJobThread(agentDir, job) {
|
|
65
|
+
try {
|
|
66
|
+
mkdirSync(join(agentDir, '.messaging'), { recursive: true });
|
|
67
|
+
const { app, conversation, ts, sessionId } = job;
|
|
68
|
+
appendFileSync(jobsFile(agentDir), JSON.stringify({ app, conversation, ts, sessionId, at: new Date().toISOString() }) + '\n');
|
|
69
|
+
}
|
|
70
|
+
catch (err) {
|
|
71
|
+
console.error('[loopengine] could not record the job thread:', err instanceof Error ? err.message : err);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
export function findJobThread(agentDir, app, conversation, ts) {
|
|
75
|
+
return readJobs(agentDir).filter((job) => job.app === app && job.conversation === conversation && job.ts === ts).at(-1);
|
|
76
|
+
}
|
|
77
|
+
/** The session's latest job thread: where its last message came from. */
|
|
78
|
+
export function findJobThreadBySession(agentDir, sessionId) {
|
|
79
|
+
return readJobs(agentDir).filter((job) => job.sessionId === sessionId).at(-1);
|
|
80
|
+
}
|
|
@@ -22,15 +22,18 @@ export interface NotifierFile {
|
|
|
22
22
|
* remembered from the console's own address when saving.
|
|
23
23
|
* LOOPENGINE_PUBLIC_URL, when set, wins over it. */
|
|
24
24
|
publicUrl?: string;
|
|
25
|
-
/** slack: answer replies in a request's thread
|
|
26
|
-
* Unset counts as on. */
|
|
25
|
+
/** slack, telegram: answer replies in a request's thread
|
|
26
|
+
* (core/messaging/). Unset counts as on. */
|
|
27
27
|
conversations?: boolean;
|
|
28
|
-
/** slack: the people who can decide a request by replying in
|
|
29
|
-
* thread, and whose button clicks count once any are listed. */
|
|
28
|
+
/** slack, telegram: the people who can decide a request by replying in
|
|
29
|
+
* its thread, and whose button clicks count once any are listed. */
|
|
30
30
|
approvers?: MessagingApprover[];
|
|
31
|
+
/** slack, telegram: a DM to the bot, or a mention of it, starts a job
|
|
32
|
+
* (core/messaging/jobs.ts). Unset counts as off. */
|
|
33
|
+
jobs?: boolean;
|
|
31
34
|
}
|
|
32
35
|
export interface MessagingApprover {
|
|
33
|
-
/** The app's id for the person (Slack: U0123ABC). */
|
|
36
|
+
/** The app's id for the person (Slack: U0123ABC; Telegram: 123456789). */
|
|
34
37
|
user: string;
|
|
35
38
|
/** Their name when they were added, for the console. */
|
|
36
39
|
name?: string;
|
|
@@ -41,6 +44,11 @@ export declare const NOTIFIER_SECRET_VARS: Record<NotifierFileChannel, string>;
|
|
|
41
44
|
* Approve/Deny buttons in Slack, handled by the server itself at
|
|
42
45
|
* POST /hooks/slack/<agent>; unset, messages carry only the decision link. */
|
|
43
46
|
export declare const NOTIFIER_SLACK_SIGNING_SECRET_VAR = "LOOPENGINE_NOTIFIER_SLACK_SIGNING_SECRET";
|
|
47
|
+
/** A secret LoopEngine makes when it points a Telegram bot's webhook at
|
|
48
|
+
* POST /hooks/telegram/<agent>; Telegram sends it back with every update.
|
|
49
|
+
* Set, requests get Approve/Deny buttons and replies reach the agent;
|
|
50
|
+
* unset, messages carry only the decision link. */
|
|
51
|
+
export declare const NOTIFIER_TELEGRAM_WEBHOOK_SECRET_VAR = "LOOPENGINE_NOTIFIER_TELEGRAM_WEBHOOK_SECRET";
|
|
44
52
|
/** In the order the Messaging tab lists them: the messaging apps, then
|
|
45
53
|
* the webhook. */
|
|
46
54
|
export declare const NOTIFIER_CHANNELS: NotifierFileChannel[];
|
|
@@ -48,6 +48,13 @@ export const NOTIFIER_SECRET_VARS = {
|
|
|
48
48
|
* Approve/Deny buttons in Slack, handled by the server itself at
|
|
49
49
|
* POST /hooks/slack/<agent>; unset, messages carry only the decision link. */
|
|
50
50
|
export const NOTIFIER_SLACK_SIGNING_SECRET_VAR = 'LOOPENGINE_NOTIFIER_SLACK_SIGNING_SECRET';
|
|
51
|
+
/** A secret LoopEngine makes when it points a Telegram bot's webhook at
|
|
52
|
+
* POST /hooks/telegram/<agent>; Telegram sends it back with every update.
|
|
53
|
+
* Set, requests get Approve/Deny buttons and replies reach the agent;
|
|
54
|
+
* unset, messages carry only the decision link. */
|
|
55
|
+
export const NOTIFIER_TELEGRAM_WEBHOOK_SECRET_VAR = 'LOOPENGINE_NOTIFIER_TELEGRAM_WEBHOOK_SECRET';
|
|
56
|
+
/** The apps whose people can talk to the agent there (core/messaging/). */
|
|
57
|
+
const TWO_WAY = ['slack', 'telegram'];
|
|
51
58
|
/** In the order the Messaging tab lists them: the messaging apps, then
|
|
52
59
|
* the webhook. */
|
|
53
60
|
export const NOTIFIER_CHANNELS = ['slack', 'telegram', 'lark', 'email', 'webhook'];
|
|
@@ -83,17 +90,30 @@ export function validateNotifierFile(input) {
|
|
|
83
90
|
if (raw.conversations !== undefined) {
|
|
84
91
|
if (typeof raw.conversations !== 'boolean')
|
|
85
92
|
throw new NotifierFileError('conversations must be true or false');
|
|
86
|
-
if (channel
|
|
93
|
+
if (TWO_WAY.includes(channel))
|
|
87
94
|
file.conversations = raw.conversations;
|
|
88
95
|
}
|
|
89
|
-
if (raw.
|
|
96
|
+
if (raw.jobs !== undefined) {
|
|
97
|
+
if (typeof raw.jobs !== 'boolean')
|
|
98
|
+
throw new NotifierFileError('jobs must be true or false');
|
|
99
|
+
if (TWO_WAY.includes(channel) && raw.jobs)
|
|
100
|
+
file.jobs = true;
|
|
101
|
+
}
|
|
102
|
+
if (raw.approvers !== undefined && TWO_WAY.includes(channel)) {
|
|
90
103
|
if (!Array.isArray(raw.approvers))
|
|
91
104
|
throw new NotifierFileError('approvers must be a list');
|
|
92
105
|
const approvers = [];
|
|
93
106
|
for (const a of raw.approvers) {
|
|
107
|
+
// Telegram ids are numbers; YAML may read one back as a number.
|
|
94
108
|
const entry = a;
|
|
95
|
-
if (
|
|
109
|
+
if (entry && channel === 'telegram' && typeof entry.user === 'number')
|
|
110
|
+
entry.user = String(entry.user);
|
|
111
|
+
if (channel === 'slack' && (!entry || typeof entry.user !== 'string' || !/^[A-Z0-9]{2,32}$/.test(entry.user)))
|
|
96
112
|
throw new NotifierFileError('each approver needs a Slack user id, like U0123ABC');
|
|
113
|
+
if (channel === 'telegram' && (!entry || typeof entry.user !== 'string' || !/^[0-9]{1,20}$/.test(entry.user)))
|
|
114
|
+
throw new NotifierFileError('each approver needs a Telegram user id, like 123456789');
|
|
115
|
+
if (!entry || typeof entry.user !== 'string')
|
|
116
|
+
continue;
|
|
97
117
|
if (approvers.some((x) => x.user === entry.user))
|
|
98
118
|
continue;
|
|
99
119
|
approvers.push({ user: entry.user, ...(typeof entry.name === 'string' && entry.name.trim() ? { name: entry.name.trim().slice(0, 80) } : {}) });
|
|
@@ -287,9 +307,24 @@ export function notifierConfigFromFile(file, env, agentName = '') {
|
|
|
287
307
|
// No card buttons: nothing built in handles Lark's card callbacks yet,
|
|
288
308
|
// so a console-configured Lark notifier relies on the decision link.
|
|
289
309
|
return { config: { channel: 'lark', config: { appId: file.appId, appSecret: secret, chatId: file.chatId, publicUrl: file.publicUrl, buttons: false }, events: file.events } };
|
|
290
|
-
case 'telegram':
|
|
291
|
-
//
|
|
292
|
-
|
|
310
|
+
case 'telegram': {
|
|
311
|
+
// Buttons once the bot's webhook points at /hooks/telegram/<agent>
|
|
312
|
+
// (the secret is made then); otherwise the decision link does the job.
|
|
313
|
+
const webhook = Boolean(env.get(NOTIFIER_TELEGRAM_WEBHOOK_SECRET_VAR));
|
|
314
|
+
return {
|
|
315
|
+
config: {
|
|
316
|
+
channel: 'telegram',
|
|
317
|
+
config: {
|
|
318
|
+
botToken: secret,
|
|
319
|
+
chatId: file.chatId,
|
|
320
|
+
publicUrl: file.publicUrl,
|
|
321
|
+
buttons: webhook,
|
|
322
|
+
replyHint: webhook && file.conversations !== false && process.env.LOOPENGINE_PLAN !== 'free',
|
|
323
|
+
},
|
|
324
|
+
events: file.events,
|
|
325
|
+
},
|
|
326
|
+
};
|
|
327
|
+
}
|
|
293
328
|
case 'email':
|
|
294
329
|
// Links to the decision page, like Lark without buttons; the secret
|
|
295
330
|
// is the provider's API key.
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
export declare class CallbackUrlError extends Error {
|
|
2
|
+
}
|
|
3
|
+
export type RunCallbackEvent = 'run.done' | 'run.failed' | 'run.awaiting_approval';
|
|
4
|
+
/** Whether an IP address is private, loopback, link-local or otherwise
|
|
5
|
+
* not on the public internet. */
|
|
6
|
+
export declare function isPrivateAddress(address: string): boolean;
|
|
7
|
+
/** Checks a callback URL as given; returns it normalised. Throws
|
|
8
|
+
* CallbackUrlError saying what's wrong. */
|
|
9
|
+
export declare function checkCallbackUrl(raw: unknown): string;
|
|
10
|
+
/** The workspace's callback secret: LOOPENGINE_CALLBACK_SECRET, or one
|
|
11
|
+
* made once and kept in the scheduler's folder. */
|
|
12
|
+
export declare function callbackSecret(): string;
|
|
13
|
+
/** The signature header for a body sent at `timestamp` (unix seconds). */
|
|
14
|
+
export declare function signCallback(body: string, timestamp: number, secret?: string): string;
|
|
15
|
+
export interface CallbackDelivery {
|
|
16
|
+
event: RunCallbackEvent;
|
|
17
|
+
delivered: boolean;
|
|
18
|
+
attempts: number;
|
|
19
|
+
status_code?: number;
|
|
20
|
+
error?: string;
|
|
21
|
+
at: string;
|
|
22
|
+
}
|
|
23
|
+
export interface DeliverOptions {
|
|
24
|
+
/** Waits between attempts, in ms; its length + 1 is the attempts made. */
|
|
25
|
+
backoffMs?: number[];
|
|
26
|
+
timeoutMs?: number;
|
|
27
|
+
fetch?: typeof fetch;
|
|
28
|
+
}
|
|
29
|
+
/** Posts `payload` to `url` for `event`, retrying a failed attempt (an
|
|
30
|
+
* error, or a status other than 2xx) after each wait in backoffMs. The
|
|
31
|
+
* host is looked up first, and a private address is refused (unless
|
|
32
|
+
* local callbacks are allowed), so a public name can't lead inward. */
|
|
33
|
+
export declare function deliverCallback(url: string, event: RunCallbackEvent, payload: unknown, options?: DeliverOptions): Promise<CallbackDelivery>;
|
|
34
|
+
/** What a run's callback carries: the event and the run's record, without
|
|
35
|
+
* its session id, its first message or past callback deliveries. */
|
|
36
|
+
export declare function runCallbackPayload(event: RunCallbackEvent, run: object): {
|
|
37
|
+
event: RunCallbackEvent;
|
|
38
|
+
run: Record<string, unknown>;
|
|
39
|
+
};
|