loopengine 0.3.1 → 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/system-agents/workflow-planner.d.ts +37 -4
- package/dist/core/system-agents/workflow-planner.js +79 -5
- package/dist/core/workflow-triggers.d.ts +15 -5
- package/dist/core/workflow-triggers.js +25 -8
- 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 +1 -1
|
@@ -8,6 +8,7 @@ import { RedisQueueApprover } from './http-notify-triggers/redis.js';
|
|
|
8
8
|
import { agentDirFor, createAgentEnv } from './agent-env.js';
|
|
9
9
|
import { notifierConfigFromFile, readActiveConnections, readNotifierFile } from './notifier-file.js';
|
|
10
10
|
import { recordThread } from './messaging/threads.js';
|
|
11
|
+
import { recordTelegramMessage } from './messaging/connectors/telegram/store.js';
|
|
11
12
|
const EMPTY = {};
|
|
12
13
|
// Keyed by the AgentConfig object itself, not its httpNotifier field
|
|
13
14
|
// alone — resolveHttpNotifier is called on every runAgent()/resumeAgent()
|
|
@@ -129,7 +130,7 @@ function combineResolved(list) {
|
|
|
129
130
|
f(context); } : undefined,
|
|
130
131
|
};
|
|
131
132
|
}
|
|
132
|
-
/** `agentDir`: where a Slack request's thread is recorded, so replies in
|
|
133
|
+
/** `agentDir`: where a Slack or Telegram request's thread is recorded, so replies in
|
|
133
134
|
* it reach the agent (core/messaging/). */
|
|
134
135
|
function buildResolved(httpNotifier, agentDir) {
|
|
135
136
|
const events = new Set(httpNotifier.events);
|
|
@@ -178,8 +179,18 @@ function buildResolved(httpNotifier, agentDir) {
|
|
|
178
179
|
break;
|
|
179
180
|
}
|
|
180
181
|
case 'telegram': {
|
|
181
|
-
const { botToken, chatId, publicUrl } = httpNotifier.config;
|
|
182
|
-
|
|
182
|
+
const { botToken, chatId, publicUrl, buttons, replyHint } = httpNotifier.config;
|
|
183
|
+
// Each message starts a thread of its own (Telegram has none, see
|
|
184
|
+
// core/messaging/connectors/telegram/store.ts); a request's is
|
|
185
|
+
// linked to it, so replies to it reach the agent.
|
|
186
|
+
const telegram = new TelegramNotifier({
|
|
187
|
+
botToken, chatId, publicUrl, buttons, replyHint,
|
|
188
|
+
onPosted: ({ chat, messageId, text, pendingId, kind }) => {
|
|
189
|
+
recordTelegramMessage(agentDir, { chat, id: messageId, root: messageId, fromBot: true, text });
|
|
190
|
+
if (pendingId && kind)
|
|
191
|
+
recordThread(agentDir, { app: 'telegram', conversation: chat, ts: messageId, pendingId, kind });
|
|
192
|
+
},
|
|
193
|
+
});
|
|
183
194
|
return {
|
|
184
195
|
approver: events.has('approval') ? telegram : undefined,
|
|
185
196
|
questionHandler: events.has('question') ? telegram : undefined,
|
|
@@ -3,16 +3,35 @@ import type { DurableQuestionHandler } from '../agent-config.js';
|
|
|
3
3
|
import { type DeliveryResult } from './webhook.js';
|
|
4
4
|
/** The Messaging tab's Send test. */
|
|
5
5
|
export declare function sendTestTelegramMessage(botToken: string, chatId: string, text: string): Promise<DeliveryResult>;
|
|
6
|
+
/** Where a message landed, and which request it is when it's one. */
|
|
7
|
+
export interface TelegramPosted {
|
|
8
|
+
chat: string;
|
|
9
|
+
messageId: string;
|
|
10
|
+
/** The Markdown it was built from, for the thread's record. */
|
|
11
|
+
text: string;
|
|
12
|
+
pendingId?: string;
|
|
13
|
+
kind?: 'approval' | 'question';
|
|
14
|
+
}
|
|
6
15
|
export declare class TelegramNotifier implements DurableApprover, DurableQuestionHandler {
|
|
7
16
|
private readonly botToken;
|
|
8
17
|
private readonly chatId;
|
|
9
18
|
private readonly publicUrl?;
|
|
19
|
+
private readonly buttons;
|
|
20
|
+
private readonly replyHint;
|
|
21
|
+
private readonly onPosted?;
|
|
10
22
|
/** `publicUrl`: same meaning as SlackNotifier's — each message links to
|
|
11
|
-
* the decision page when the server's address is known.
|
|
23
|
+
* the decision page when the server's address is known. `buttons`:
|
|
24
|
+
* add inline buttons, which only work once the bot's webhook points at
|
|
25
|
+
* this server; default false. `replyHint`: say replies are answered.
|
|
26
|
+
* `onPosted`: told where each message landed, so replies to it can be
|
|
27
|
+
* matched to its request (core/messaging/threads.ts). */
|
|
12
28
|
constructor(options: {
|
|
13
29
|
botToken: string;
|
|
14
30
|
chatId: string;
|
|
15
31
|
publicUrl?: string;
|
|
32
|
+
buttons?: boolean;
|
|
33
|
+
replyHint?: boolean;
|
|
34
|
+
onPosted?: (posted: TelegramPosted) => void;
|
|
16
35
|
});
|
|
17
36
|
private decideLine;
|
|
18
37
|
private send;
|
|
@@ -1,17 +1,22 @@
|
|
|
1
1
|
// The sending half of a Telegram-backed DurableApprover/DurableQuestionHandler,
|
|
2
2
|
// so AgentConfig.httpNotifier (core/http-notifier.ts) can construct one
|
|
3
|
-
// for `channel: 'telegram'`, the way it does for Slack
|
|
4
|
-
//
|
|
3
|
+
// for `channel: 'telegram'`, the way it does for Slack (read ./slack.ts
|
|
4
|
+
// first; this mirrors it).
|
|
5
5
|
//
|
|
6
6
|
// Posts through the Bot API's sendMessage (core.telegram.org/bots/api)
|
|
7
7
|
// to one chat: a group, a channel the bot can post in, or a person who
|
|
8
|
-
// has started the bot.
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
12
|
-
//
|
|
8
|
+
// has started the bot. Each request links to the decision page; with
|
|
9
|
+
// `buttons` (the bot's webhook points at this server, see
|
|
10
|
+
// core/messaging/connectors/telegram/) it also gets Approve/Deny or a
|
|
11
|
+
// question's options as inline buttons, handled at
|
|
12
|
+
// POST /hooks/telegram/<agent>. Messages are HTML (parse_mode), built
|
|
13
|
+
// from Markdown with everything the agent wrote escaped.
|
|
13
14
|
import { randomUUID } from 'node:crypto';
|
|
14
15
|
import { decidePath } from '../approval-links.js';
|
|
16
|
+
import { formatArgs } from '../messaging/format-args.js';
|
|
17
|
+
import { callTelegram } from '../messaging/connectors/telegram/index.js';
|
|
18
|
+
import { telegramEscape, toTelegramHtml } from '../messaging/connectors/telegram/format.js';
|
|
19
|
+
import { telegramCallbackData } from '../messaging/connectors/telegram/updates.js';
|
|
15
20
|
import { publicBaseUrl } from './webhook.js';
|
|
16
21
|
// Telegram's limit for one message's text.
|
|
17
22
|
const MAX_TEXT = 4096;
|
|
@@ -37,35 +42,76 @@ export class TelegramNotifier {
|
|
|
37
42
|
botToken;
|
|
38
43
|
chatId;
|
|
39
44
|
publicUrl;
|
|
45
|
+
buttons;
|
|
46
|
+
replyHint;
|
|
47
|
+
onPosted;
|
|
40
48
|
/** `publicUrl`: same meaning as SlackNotifier's — each message links to
|
|
41
|
-
* the decision page when the server's address is known.
|
|
49
|
+
* the decision page when the server's address is known. `buttons`:
|
|
50
|
+
* add inline buttons, which only work once the bot's webhook points at
|
|
51
|
+
* this server; default false. `replyHint`: say replies are answered.
|
|
52
|
+
* `onPosted`: told where each message landed, so replies to it can be
|
|
53
|
+
* matched to its request (core/messaging/threads.ts). */
|
|
42
54
|
constructor(options) {
|
|
43
55
|
this.botToken = options.botToken;
|
|
44
56
|
this.chatId = options.chatId;
|
|
45
57
|
this.publicUrl = options.publicUrl;
|
|
58
|
+
this.buttons = options.buttons ?? false;
|
|
59
|
+
this.replyHint = options.replyHint ?? false;
|
|
60
|
+
this.onPosted = options.onPosted;
|
|
46
61
|
}
|
|
47
62
|
decideLine(pendingId, kind) {
|
|
48
63
|
const base = publicBaseUrl(this.publicUrl);
|
|
49
|
-
return base ?
|
|
64
|
+
return base ? `[${kind === 'approval' ? 'Open to approve or deny' : 'Open to answer'}](${base}${decidePath(pendingId, kind)})` : '';
|
|
50
65
|
}
|
|
51
|
-
send(
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
66
|
+
send(markdown, label, keyboard, request) {
|
|
67
|
+
// Not awaited, by DurableApprover's contract; a failure is logged.
|
|
68
|
+
void callTelegram(this.botToken, 'sendMessage', {
|
|
69
|
+
chat_id: this.chatId,
|
|
70
|
+
text: toTelegramHtml(markdown),
|
|
71
|
+
parse_mode: 'HTML',
|
|
72
|
+
link_preview_options: { is_disabled: true },
|
|
73
|
+
...(keyboard?.length ? { reply_markup: { inline_keyboard: keyboard } } : {}),
|
|
74
|
+
}).then((sent) => {
|
|
75
|
+
if (!sent.ok || !sent.result) {
|
|
76
|
+
console.error(`[loopengine] TelegramNotifier: send message failed for ${label}:`, sent.description);
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
this.onPosted?.({ chat: String(sent.result.chat?.id ?? this.chatId), messageId: String(sent.result.message_id), text: markdown, ...(request ?? {}) });
|
|
55
80
|
});
|
|
56
81
|
}
|
|
57
82
|
requestDurableApproval(tool, args, scope, reason, pendingId = randomUUID()) {
|
|
58
|
-
|
|
83
|
+
const lines = [
|
|
84
|
+
`**Approval requested:** \`${tool}\``,
|
|
85
|
+
formatArgs(args, telegramEscape),
|
|
86
|
+
`**Why:** ${telegramEscape(reason)}`,
|
|
87
|
+
this.decideLine(pendingId, 'approval'),
|
|
88
|
+
telegramEscape(`${scope.agent} · ${scope.tenant}/${scope.environment}`),
|
|
89
|
+
this.replyHint ? 'Reply to this message to ask about it.' : '',
|
|
90
|
+
];
|
|
91
|
+
const keyboard = this.buttons
|
|
92
|
+
? [[
|
|
93
|
+
{ text: 'Approve', callback_data: telegramCallbackData({ type: 'decide', decision: 'approve' }, pendingId) },
|
|
94
|
+
{ text: 'Deny', callback_data: telegramCallbackData({ type: 'decide', decision: 'deny' }, pendingId) },
|
|
95
|
+
]]
|
|
96
|
+
: undefined;
|
|
97
|
+
this.send(lines.filter(Boolean).join('\n'), `pendingId '${pendingId}'`, keyboard, { pendingId, kind: 'approval' });
|
|
59
98
|
return { pendingId };
|
|
60
99
|
}
|
|
61
100
|
notifyPendingQuestion(question, options, agent, sessionId, pendingId = randomUUID()) {
|
|
62
|
-
const
|
|
63
|
-
|
|
101
|
+
const lines = [
|
|
102
|
+
`**Question from ${telegramEscape(agent)}${sessionId ? ` (session ${telegramEscape(sessionId)})` : ''}:**`,
|
|
103
|
+
telegramEscape(question),
|
|
104
|
+
this.decideLine(pendingId, 'question'),
|
|
105
|
+
this.replyHint ? (options?.length ? 'Or reply to this message with another answer.' : 'Reply to this message to answer.') : '',
|
|
106
|
+
];
|
|
107
|
+
// One option per row: a long option would squeeze its neighbours.
|
|
108
|
+
const keyboard = this.buttons ? (options ?? []).slice(0, 20).map((option, i) => [{ text: option.slice(0, 64), callback_data: telegramCallbackData({ type: 'answer_option', index: i }, pendingId) }]) : undefined;
|
|
109
|
+
this.send(lines.filter(Boolean).join('\n'), `pendingId '${pendingId}'`, keyboard, { pendingId, kind: 'question' });
|
|
64
110
|
return { pendingId };
|
|
65
111
|
}
|
|
66
112
|
/** A plain announcement (run started/finished, a scheduled task's
|
|
67
113
|
* result), with nothing to decide. */
|
|
68
114
|
postMessage(text) {
|
|
69
|
-
this.send(text, 'an announcement');
|
|
115
|
+
this.send(telegramEscape(text), 'an announcement');
|
|
70
116
|
}
|
|
71
117
|
}
|
|
@@ -13,6 +13,13 @@ export interface InboundMessage {
|
|
|
13
13
|
ts: string;
|
|
14
14
|
/** Set for a reply in a thread: the thread's first message id. */
|
|
15
15
|
threadTs?: string;
|
|
16
|
+
/** Sent to the agent alone: a DM, or a private chat with the bot. */
|
|
17
|
+
direct?: boolean;
|
|
18
|
+
/** Names the agent: an @mention of its bot. */
|
|
19
|
+
mentioned?: boolean;
|
|
20
|
+
/** The person's name, when the app says it, for the console's
|
|
21
|
+
* approver picker. */
|
|
22
|
+
userName?: string;
|
|
16
23
|
}
|
|
17
24
|
/** One message of a thread, as the connector reads it back. */
|
|
18
25
|
export interface ThreadMessage {
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// there (core/messaging/engine.ts). The engine owns everything about
|
|
3
3
|
// requests, decisions and safety; a connector only turns the app's own
|
|
4
4
|
// payloads into these shapes and posts back. One per app, under
|
|
5
|
-
// ./connectors/ — Slack
|
|
5
|
+
// ./connectors/ — Slack and Telegram today, Lark next.
|
|
6
6
|
//
|
|
7
7
|
// Verifying an incoming request and parsing it stay with the connector's
|
|
8
8
|
// own module (each app signs and shapes its payloads differently), and
|
|
@@ -9,6 +9,7 @@ export type SlackEvent = {
|
|
|
9
9
|
};
|
|
10
10
|
/** Reads an Events API body. Only people's new messages come through;
|
|
11
11
|
* edits, deletions, joins and every bot's messages (this agent's own
|
|
12
|
-
* included) are `ignore`.
|
|
12
|
+
* included) are `ignore`. A DM is `direct`; an app_mention is
|
|
13
|
+
* `mentioned`, with the leading @mention of the bot taken off its text. */
|
|
13
14
|
export declare function parseSlackEvent(rawBody: string): SlackEvent;
|
|
14
15
|
export declare function createSlackConnector(botToken: string): MessagingConnector;
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { slackEscape, toSlackMrkdwn } from './format.js';
|
|
2
2
|
/** Reads an Events API body. Only people's new messages come through;
|
|
3
3
|
* edits, deletions, joins and every bot's messages (this agent's own
|
|
4
|
-
* included) are `ignore`.
|
|
4
|
+
* included) are `ignore`. A DM is `direct`; an app_mention is
|
|
5
|
+
* `mentioned`, with the leading @mention of the bot taken off its text. */
|
|
5
6
|
export function parseSlackEvent(rawBody) {
|
|
6
7
|
let body;
|
|
7
8
|
try {
|
|
@@ -19,15 +20,18 @@ export function parseSlackEvent(rawBody) {
|
|
|
19
20
|
return { type: 'ignore' };
|
|
20
21
|
if (!event.user || !event.channel || !event.ts || typeof event.text !== 'string')
|
|
21
22
|
return { type: 'ignore' };
|
|
23
|
+
const mentioned = event.type === 'app_mention';
|
|
22
24
|
return {
|
|
23
25
|
type: 'message',
|
|
24
26
|
app: 'slack',
|
|
25
27
|
eventId: body.event_id,
|
|
26
28
|
conversation: event.channel,
|
|
27
29
|
user: event.user,
|
|
28
|
-
text: event.text,
|
|
30
|
+
text: mentioned ? event.text.replace(/^(\s*<@[A-Z0-9]+>)+\s*/, '') : event.text,
|
|
29
31
|
ts: event.ts,
|
|
30
32
|
...(event.thread_ts && event.thread_ts !== event.ts ? { threadTs: event.thread_ts } : {}),
|
|
33
|
+
...(event.channel_type === 'im' ? { direct: true } : {}),
|
|
34
|
+
...(mentioned ? { mentioned: true } : {}),
|
|
31
35
|
};
|
|
32
36
|
}
|
|
33
37
|
/** The engine's buttons as Slack buttons: action_id is the action (deny
|
|
@@ -17,7 +17,8 @@ export interface SlackMember {
|
|
|
17
17
|
* users:read for their names. */
|
|
18
18
|
export declare function listSlackChannelMembers(botToken: string, channel: string): Promise<SlackMember[]>;
|
|
19
19
|
/** A Slack app manifest for one agent's notifier: a bot that can post,
|
|
20
|
-
* read the channel list
|
|
20
|
+
* read the channel list, read replies in its requests' threads, and take
|
|
21
|
+
* DMs and @mentions that start jobs (core/messaging/jobs.ts), with
|
|
21
22
|
* interactivity pointed at this server and events at <that URL>/events
|
|
22
23
|
* (core/messaging/). Paste it into api.slack.com/apps →
|
|
23
24
|
* Create New App → From a manifest. */
|
|
@@ -75,18 +75,23 @@ function slackSetupError(error) {
|
|
|
75
75
|
return reasons[error ?? ''] ?? `Slack said: ${error}`;
|
|
76
76
|
}
|
|
77
77
|
/** A Slack app manifest for one agent's notifier: a bot that can post,
|
|
78
|
-
* read the channel list
|
|
78
|
+
* read the channel list, read replies in its requests' threads, and take
|
|
79
|
+
* DMs and @mentions that start jobs (core/messaging/jobs.ts), with
|
|
79
80
|
* interactivity pointed at this server and events at <that URL>/events
|
|
80
81
|
* (core/messaging/). Paste it into api.slack.com/apps →
|
|
81
82
|
* Create New App → From a manifest. */
|
|
82
83
|
export function slackAppManifest(options) {
|
|
83
84
|
const name = `LoopEngine ${options.agentName}`.slice(0, 35);
|
|
84
85
|
return {
|
|
85
|
-
display_information: { name, description: `
|
|
86
|
-
features: {
|
|
87
|
-
|
|
86
|
+
display_information: { name, description: `Jobs, approval requests and questions from the ${options.agentName} agent` },
|
|
87
|
+
features: {
|
|
88
|
+
bot_user: { display_name: name, always_online: true },
|
|
89
|
+
// A Messages tab, so people can DM the agent a job.
|
|
90
|
+
app_home: { home_tab_enabled: false, messages_tab_enabled: true, messages_tab_read_only_enabled: false },
|
|
91
|
+
},
|
|
92
|
+
oauth_config: { scopes: { bot: ['chat:write', 'channels:read', 'groups:read', 'channels:history', 'groups:history', 'users:read', 'app_mentions:read', 'im:history'] } },
|
|
88
93
|
settings: {
|
|
89
|
-
event_subscriptions: { request_url: `${options.interactivityUrl}/events`, bot_events: ['message.channels', 'message.groups'] },
|
|
94
|
+
event_subscriptions: { request_url: `${options.interactivityUrl}/events`, bot_events: ['message.channels', 'message.groups', 'message.im', 'app_mention'] },
|
|
90
95
|
interactivity: { is_enabled: true, request_url: options.interactivityUrl },
|
|
91
96
|
org_deploy_enabled: false,
|
|
92
97
|
socket_mode_enabled: false,
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
/** Marks text that isn't ours, so toTelegramHtml escapes it and leaves it
|
|
2
|
+
* as plain text. */
|
|
3
|
+
export declare function telegramEscape(text: string): string;
|
|
4
|
+
export declare function toTelegramHtml(markdown: string): string;
|
|
5
|
+
/** A call's arguments as Telegram HTML (core/messaging/format-args.ts). */
|
|
6
|
+
export declare function telegramArgs(args: Record<string, unknown>, schema?: Record<string, unknown>): string;
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
// Telegram's side of formatting: messages go out with parse_mode HTML,
|
|
2
|
+
// so the Markdown the engine and the model write is turned into the few
|
|
3
|
+
// tags Telegram knows (b, i, s, code, pre, a). Everything else is
|
|
4
|
+
// HTML-escaped, so no text can become a tag. Text that isn't ours
|
|
5
|
+
// (telegramEscape) is escaped too but never turned into formatting or a
|
|
6
|
+
// link.
|
|
7
|
+
import { formatArgs } from '../../format-args.js';
|
|
8
|
+
const OPEN = '\u0001';
|
|
9
|
+
const CLOSE = '\u0002';
|
|
10
|
+
const KEEP = /(```[\s\S]*?```|`[^`\n]*`|\u0001[\s\S]*?\u0002)/;
|
|
11
|
+
// Telegram's limit is 4096 characters of text after the tags are read;
|
|
12
|
+
// cutting the Markdown well short of it keeps the tags whole.
|
|
13
|
+
const MAX_MARKDOWN = 3800;
|
|
14
|
+
function html(text) {
|
|
15
|
+
return text.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
16
|
+
}
|
|
17
|
+
/** Marks text that isn't ours, so toTelegramHtml escapes it and leaves it
|
|
18
|
+
* as plain text. */
|
|
19
|
+
export function telegramEscape(text) {
|
|
20
|
+
return OPEN + text.replace(/[\u0001\u0002]/g, '') + CLOSE;
|
|
21
|
+
}
|
|
22
|
+
export function toTelegramHtml(markdown) {
|
|
23
|
+
const cut = markdown.length > MAX_MARKDOWN ? markdown.slice(0, MAX_MARKDOWN).trimEnd() + '\n…' : markdown;
|
|
24
|
+
return cut
|
|
25
|
+
.split(KEEP)
|
|
26
|
+
.map((part, i) => {
|
|
27
|
+
if (i % 2 === 0)
|
|
28
|
+
return convertProse(part);
|
|
29
|
+
const plain = part.replace(/[\u0001\u0002]/g, '');
|
|
30
|
+
if (part.startsWith(OPEN))
|
|
31
|
+
return html(plain);
|
|
32
|
+
if (part.startsWith('```'))
|
|
33
|
+
return `<pre>${html(plain.slice(3, -3).replace(/^[\w-]*\n/, ''))}</pre>`;
|
|
34
|
+
return `<code>${html(plain.slice(1, -1))}</code>`;
|
|
35
|
+
})
|
|
36
|
+
.join('')
|
|
37
|
+
.replace(/[\u0001\u0002]/g, '');
|
|
38
|
+
}
|
|
39
|
+
function convertProse(text) {
|
|
40
|
+
return html(text)
|
|
41
|
+
// # Heading -> bold
|
|
42
|
+
.replace(/^#{1,6}[ \t]+(.+?)[ \t]*#*[ \t]*$/gm, '<b>$1</b>')
|
|
43
|
+
// - item / * item -> • item
|
|
44
|
+
.replace(/^([ \t]*)[-*+][ \t]+/gm, '$1• ')
|
|
45
|
+
.replace(/\*\*(?=\S)([\s\S]*?\S)\*\*/g, '<b>$1</b>')
|
|
46
|
+
.replace(/__(?=\S)([\s\S]*?\S)__/g, '<b>$1</b>')
|
|
47
|
+
.replace(/(^|[^\w*])\*(?=\S)([^*\n]*?\S)\*(?![\w*])/g, '$1<i>$2</i>')
|
|
48
|
+
.replace(/(^|[^\w])_(?=\S)([^_\n]*?\S)_(?!\w)/g, '$1<i>$2</i>')
|
|
49
|
+
.replace(/~~(?=\S)([\s\S]*?\S)~~/g, '<s>$1</s>')
|
|
50
|
+
// [text](url): the URL was escaped with the rest, which is what an
|
|
51
|
+
// attribute needs, and can't hold a quote.
|
|
52
|
+
.replace(/\[([^\]\n]+)\]\((https?:\/\/[^\s)"]+)\)/g, '<a href="$2">$1</a>');
|
|
53
|
+
}
|
|
54
|
+
/** A call's arguments as Telegram HTML (core/messaging/format-args.ts). */
|
|
55
|
+
export function telegramArgs(args, schema) {
|
|
56
|
+
return toTelegramHtml(formatArgs(args, telegramEscape, schema));
|
|
57
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import type { MessagingConnector } from '../../connector.js';
|
|
2
|
+
export { telegramArgs, telegramEscape, toTelegramHtml } from './format.js';
|
|
3
|
+
export { readTelegramThread, recordTelegramMessage, telegramPeople, telegramRootOf, type TelegramPerson } from './store.js';
|
|
4
|
+
export { parseTelegramUpdate, telegramCallbackData, verifyTelegramRequest, type TelegramButton, type TelegramUpdate } from './updates.js';
|
|
5
|
+
export interface TelegramResult<T = unknown> {
|
|
6
|
+
ok: boolean;
|
|
7
|
+
result?: T;
|
|
8
|
+
description?: string;
|
|
9
|
+
}
|
|
10
|
+
/** One Bot API call. Never throws: a network failure reads as not ok. */
|
|
11
|
+
export declare function callTelegram<T = unknown>(botToken: string, method: string, body: Record<string, unknown>): Promise<TelegramResult<T>>;
|
|
12
|
+
/** A reply under `replyTo` that still goes out if that message is gone. */
|
|
13
|
+
export declare function replyTo(messageId: string): Record<string, unknown>;
|
|
14
|
+
export type InlineButton = {
|
|
15
|
+
text: string;
|
|
16
|
+
callback_data: string;
|
|
17
|
+
};
|
|
18
|
+
export declare function createTelegramConnector(botToken: string, agentDir: string): MessagingConnector;
|
|
19
|
+
/** Stops a clicked button's spinner, with a short note when there's one. */
|
|
20
|
+
export declare function answerTelegramClick(botToken: string, callbackId: string, text?: string): Promise<void>;
|
|
21
|
+
/** Replaces a message's text and takes its buttons away. */
|
|
22
|
+
export declare function editTelegramMessage(botToken: string, chat: string, messageId: string, text: string): Promise<void>;
|
|
23
|
+
/** The bot's @username (without @), asked once an hour per token; how a
|
|
24
|
+
* mention of it is spotted. */
|
|
25
|
+
export declare function telegramBotUsername(botToken: string): Promise<string | undefined>;
|
|
26
|
+
/** Points the bot's updates at `url` with `secret` as the secret_token
|
|
27
|
+
* (./updates.ts checks it). Only messages and button clicks are sent. */
|
|
28
|
+
export declare function setTelegramWebhook(botToken: string, url: string, secret: string): Promise<{
|
|
29
|
+
ok: boolean;
|
|
30
|
+
error?: string;
|
|
31
|
+
botUsername?: string;
|
|
32
|
+
}>;
|
|
33
|
+
/** Where the bot's updates go now, and the last delivery error Telegram saw. */
|
|
34
|
+
export declare function telegramWebhookInfo(botToken: string): Promise<{
|
|
35
|
+
ok: boolean;
|
|
36
|
+
url?: string;
|
|
37
|
+
lastError?: string;
|
|
38
|
+
error?: string;
|
|
39
|
+
}>;
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import { telegramEscape, toTelegramHtml } from './format.js';
|
|
2
|
+
import { readTelegramThread, recordTelegramMessage, telegramPeople } from './store.js';
|
|
3
|
+
import { telegramCallbackData } from './updates.js';
|
|
4
|
+
export { telegramArgs, telegramEscape, toTelegramHtml } from './format.js';
|
|
5
|
+
export { readTelegramThread, recordTelegramMessage, telegramPeople, telegramRootOf } from './store.js';
|
|
6
|
+
export { parseTelegramUpdate, telegramCallbackData, verifyTelegramRequest } from './updates.js';
|
|
7
|
+
/** One Bot API call. Never throws: a network failure reads as not ok. */
|
|
8
|
+
export async function callTelegram(botToken, method, body) {
|
|
9
|
+
try {
|
|
10
|
+
const res = await fetch(`https://api.telegram.org/bot${botToken}/${method}`, {
|
|
11
|
+
method: 'POST',
|
|
12
|
+
headers: { 'content-type': 'application/json' },
|
|
13
|
+
body: JSON.stringify(body),
|
|
14
|
+
});
|
|
15
|
+
const parsed = (await res.json().catch(() => ({})));
|
|
16
|
+
return parsed.ok ? parsed : { ok: false, description: parsed.description || `HTTP ${res.status}` };
|
|
17
|
+
}
|
|
18
|
+
catch (err) {
|
|
19
|
+
return { ok: false, description: err instanceof Error ? err.message : String(err) };
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
/** A reply under `replyTo` that still goes out if that message is gone. */
|
|
23
|
+
export function replyTo(messageId) {
|
|
24
|
+
return { reply_parameters: { message_id: Number(messageId), allow_sending_without_reply: true } };
|
|
25
|
+
}
|
|
26
|
+
/** The engine's buttons as one row of Telegram inline buttons. */
|
|
27
|
+
function choiceKeyboard(choices) {
|
|
28
|
+
return [
|
|
29
|
+
choices.map((choice) => ({
|
|
30
|
+
text: choice.label,
|
|
31
|
+
callback_data: telegramCallbackData(choice.action === 'deny' ? { type: 'decide', decision: 'deny' } : { type: 'action', action: choice.action }, choice.pendingId),
|
|
32
|
+
})),
|
|
33
|
+
];
|
|
34
|
+
}
|
|
35
|
+
/** How a person shows in a message: their @username (which Telegram
|
|
36
|
+
* links), else their name. */
|
|
37
|
+
function personLabel(agentDir, user) {
|
|
38
|
+
const person = telegramPeople(agentDir).find((p) => p.id === user);
|
|
39
|
+
if (person?.username)
|
|
40
|
+
return `@${person.username}`;
|
|
41
|
+
return (person?.name ?? `user ${user}`).replace(/[*_~`[\]<>]/g, '');
|
|
42
|
+
}
|
|
43
|
+
export function createTelegramConnector(botToken, agentDir) {
|
|
44
|
+
const send = async (conversation, threadTs, text, keyboard) => {
|
|
45
|
+
const sent = await callTelegram(botToken, 'sendMessage', {
|
|
46
|
+
chat_id: conversation,
|
|
47
|
+
text: toTelegramHtml(text),
|
|
48
|
+
parse_mode: 'HTML',
|
|
49
|
+
link_preview_options: { is_disabled: true },
|
|
50
|
+
...replyTo(threadTs),
|
|
51
|
+
...(keyboard ? { reply_markup: { inline_keyboard: keyboard } } : {}),
|
|
52
|
+
});
|
|
53
|
+
if (!sent.ok || !sent.result) {
|
|
54
|
+
console.error('[loopengine] Telegram sendMessage (thread reply) failed:', sent.description);
|
|
55
|
+
return undefined;
|
|
56
|
+
}
|
|
57
|
+
const id = String(sent.result.message_id);
|
|
58
|
+
recordTelegramMessage(agentDir, { chat: conversation, id, root: threadTs, fromBot: true, text });
|
|
59
|
+
return id;
|
|
60
|
+
};
|
|
61
|
+
return {
|
|
62
|
+
app: 'telegram',
|
|
63
|
+
name: 'Telegram',
|
|
64
|
+
mention: (user) => personLabel(agentDir, user),
|
|
65
|
+
escape: telegramEscape,
|
|
66
|
+
async fetchThread(conversation, threadTs) {
|
|
67
|
+
// Without telegramEscape's markers, which are for formatting only.
|
|
68
|
+
return readTelegramThread(agentDir, conversation, threadTs).map((m) => ({ ...m, text: m.text.replace(/[\u0001\u0002]/g, '') }));
|
|
69
|
+
},
|
|
70
|
+
async postInThread(conversation, threadTs, text) {
|
|
71
|
+
await send(conversation, threadTs, text);
|
|
72
|
+
},
|
|
73
|
+
async postChoice(conversation, threadTs, text, choices) {
|
|
74
|
+
return send(conversation, threadTs, text, choiceKeyboard(choices));
|
|
75
|
+
},
|
|
76
|
+
async replaceMessage(conversation, ts, text) {
|
|
77
|
+
// Editing without reply_markup also takes the buttons away.
|
|
78
|
+
const edited = await callTelegram(botToken, 'editMessageText', {
|
|
79
|
+
chat_id: conversation,
|
|
80
|
+
message_id: Number(ts),
|
|
81
|
+
text: toTelegramHtml(text),
|
|
82
|
+
parse_mode: 'HTML',
|
|
83
|
+
link_preview_options: { is_disabled: true },
|
|
84
|
+
});
|
|
85
|
+
if (!edited.ok)
|
|
86
|
+
console.error('[loopengine] Telegram editMessageText failed:', edited.description);
|
|
87
|
+
},
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
/** Stops a clicked button's spinner, with a short note when there's one. */
|
|
91
|
+
export async function answerTelegramClick(botToken, callbackId, text) {
|
|
92
|
+
const answered = await callTelegram(botToken, 'answerCallbackQuery', { callback_query_id: callbackId, ...(text ? { text: text.slice(0, 200) } : {}) });
|
|
93
|
+
if (!answered.ok)
|
|
94
|
+
console.error('[loopengine] Telegram answerCallbackQuery failed:', answered.description);
|
|
95
|
+
}
|
|
96
|
+
/** Replaces a message's text and takes its buttons away. */
|
|
97
|
+
export async function editTelegramMessage(botToken, chat, messageId, text) {
|
|
98
|
+
const edited = await callTelegram(botToken, 'editMessageText', { chat_id: chat, message_id: Number(messageId), text: toTelegramHtml(text), parse_mode: 'HTML', link_preview_options: { is_disabled: true } });
|
|
99
|
+
if (!edited.ok)
|
|
100
|
+
console.error('[loopengine] Telegram editMessageText failed:', edited.description);
|
|
101
|
+
}
|
|
102
|
+
const botNames = new Map();
|
|
103
|
+
/** The bot's @username (without @), asked once an hour per token; how a
|
|
104
|
+
* mention of it is spotted. */
|
|
105
|
+
export async function telegramBotUsername(botToken) {
|
|
106
|
+
const cached = botNames.get(botToken);
|
|
107
|
+
if (cached && Date.now() - cached.at < 60 * 60 * 1000)
|
|
108
|
+
return cached.username;
|
|
109
|
+
const me = await callTelegram(botToken, 'getMe', {});
|
|
110
|
+
if (!me.ok || !me.result?.username)
|
|
111
|
+
return cached?.username;
|
|
112
|
+
botNames.set(botToken, { username: me.result.username, at: Date.now() });
|
|
113
|
+
return me.result.username;
|
|
114
|
+
}
|
|
115
|
+
/** Points the bot's updates at `url` with `secret` as the secret_token
|
|
116
|
+
* (./updates.ts checks it). Only messages and button clicks are sent. */
|
|
117
|
+
export async function setTelegramWebhook(botToken, url, secret) {
|
|
118
|
+
const set = await callTelegram(botToken, 'setWebhook', { url, secret_token: secret, allowed_updates: ['message', 'callback_query'] });
|
|
119
|
+
if (!set.ok)
|
|
120
|
+
return { ok: false, error: telegramSetupError(set.description) };
|
|
121
|
+
return { ok: true, botUsername: await telegramBotUsername(botToken) };
|
|
122
|
+
}
|
|
123
|
+
/** Where the bot's updates go now, and the last delivery error Telegram saw. */
|
|
124
|
+
export async function telegramWebhookInfo(botToken) {
|
|
125
|
+
const info = await callTelegram(botToken, 'getWebhookInfo', {});
|
|
126
|
+
if (!info.ok)
|
|
127
|
+
return { ok: false, error: telegramSetupError(info.description) };
|
|
128
|
+
return { ok: true, url: info.result?.url || undefined, ...(info.result?.last_error_message ? { lastError: info.result.last_error_message } : {}) };
|
|
129
|
+
}
|
|
130
|
+
function telegramSetupError(description) {
|
|
131
|
+
if (/unauthorized|not found/i.test(description ?? ''))
|
|
132
|
+
return 'Telegram rejected the bot token; copy it from @BotFather again';
|
|
133
|
+
if (/https/i.test(description ?? ''))
|
|
134
|
+
return 'Telegram only sends updates to an https address; set LOOPENGINE_PUBLIC_URL to this server\'s https address';
|
|
135
|
+
return `Telegram said: ${description ?? 'no answer'}`;
|
|
136
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import type { ThreadMessage } from '../../connector.js';
|
|
2
|
+
export interface TelegramEntry {
|
|
3
|
+
chat: string;
|
|
4
|
+
/** The message's own id. */
|
|
5
|
+
id: string;
|
|
6
|
+
/** The first message of its thread (itself when it starts one). */
|
|
7
|
+
root: string;
|
|
8
|
+
/** A person's id; unset for the agent's own messages. */
|
|
9
|
+
user?: string;
|
|
10
|
+
fromBot: boolean;
|
|
11
|
+
text: string;
|
|
12
|
+
/** A person's name and @username, when Telegram sent them. */
|
|
13
|
+
name?: string;
|
|
14
|
+
username?: string;
|
|
15
|
+
at: string;
|
|
16
|
+
}
|
|
17
|
+
/** Never throws: a message that can't be kept only loses its context. */
|
|
18
|
+
export declare function recordTelegramMessage(agentDir: string, entry: Omit<TelegramEntry, 'at'>): void;
|
|
19
|
+
/** The thread a message belongs to: its recorded root, else the message
|
|
20
|
+
* itself. */
|
|
21
|
+
export declare function telegramRootOf(agentDir: string, chat: string, id: string): string;
|
|
22
|
+
/** A thread's messages, oldest first, at most the latest 100. */
|
|
23
|
+
export declare function readTelegramThread(agentDir: string, chat: string, root: string): ThreadMessage[];
|
|
24
|
+
export interface TelegramPerson {
|
|
25
|
+
id: string;
|
|
26
|
+
name: string;
|
|
27
|
+
username?: string;
|
|
28
|
+
}
|
|
29
|
+
/** Everyone who has written to the agent, newest first, optionally in
|
|
30
|
+
* one chat only. */
|
|
31
|
+
export declare function telegramPeople(agentDir: string, chat?: string): TelegramPerson[];
|