@timqi/pier 0.0.1
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/LICENSE +661 -0
- package/README.md +97 -0
- package/dist/agent/config.js +133 -0
- package/dist/agent/credentials.js +179 -0
- package/dist/agent/events.js +253 -0
- package/dist/agent/models.js +15 -0
- package/dist/agent/pi.js +296 -0
- package/dist/boards/boards.js +200 -0
- package/dist/boards/pier.css +445 -0
- package/dist/channels/chains.js +67 -0
- package/dist/channels/chunk.js +28 -0
- package/dist/channels/commands.js +28 -0
- package/dist/channels/config.js +172 -0
- package/dist/channels/control.js +71 -0
- package/dist/channels/conversations.js +65 -0
- package/dist/channels/gatekeeper.js +63 -0
- package/dist/channels/panel.js +233 -0
- package/dist/channels/receipts.js +104 -0
- package/dist/channels/routes.js +110 -0
- package/dist/channels/runtime.js +76 -0
- package/dist/channels/slack-api.js +296 -0
- package/dist/channels/slack-directory.js +77 -0
- package/dist/channels/slack-outbound.js +121 -0
- package/dist/channels/slack-panel.js +122 -0
- package/dist/channels/slack-render.js +214 -0
- package/dist/channels/slack-tool.js +334 -0
- package/dist/channels/slack.js +510 -0
- package/dist/channels/telegram-api.js +78 -0
- package/dist/channels/telegram-panel.js +113 -0
- package/dist/channels/telegram-render.js +96 -0
- package/dist/channels/telegram.js +473 -0
- package/dist/channels/types.js +27 -0
- package/dist/cli.js +101 -0
- package/dist/core/hub.js +53 -0
- package/dist/core/identity.js +66 -0
- package/dist/core/queue.js +11 -0
- package/dist/core/reply.js +202 -0
- package/dist/core/router.js +189 -0
- package/dist/core/types.js +7 -0
- package/dist/db.js +268 -0
- package/dist/log.js +55 -0
- package/dist/main.js +183 -0
- package/dist/paths.js +17 -0
- package/dist/secrets.js +191 -0
- package/dist/service.js +134 -0
- package/dist/settings.js +57 -0
- package/dist/tasks/agent.js +197 -0
- package/dist/tasks/callbacks.js +140 -0
- package/dist/tasks/command.js +74 -0
- package/dist/tasks/definitions.js +316 -0
- package/dist/tasks/execution.js +141 -0
- package/dist/tasks/groups.js +187 -0
- package/dist/tasks/messages.js +248 -0
- package/dist/tasks/routes.js +219 -0
- package/dist/tasks/runs.js +104 -0
- package/dist/tasks/service.js +282 -0
- package/dist/tasks/store.js +168 -0
- package/dist/tasks/tool.js +281 -0
- package/dist/tasks/types.js +5 -0
- package/dist/web/auth.js +280 -0
- package/dist/web/files.js +167 -0
- package/dist/web/public/assets/index-8CinH1uR.css +2 -0
- package/dist/web/public/assets/index-DAgP1Gq8.js +78 -0
- package/dist/web/public/icon-192.png +0 -0
- package/dist/web/public/icon-32.png +0 -0
- package/dist/web/public/icon-512.png +0 -0
- package/dist/web/public/icon-maskable-512.png +0 -0
- package/dist/web/public/icon-touch-192.png +0 -0
- package/dist/web/public/icon.svg +19 -0
- package/dist/web/public/index.html +251 -0
- package/dist/web/public/manifest.webmanifest +16 -0
- package/dist/web/public/sw.js +21 -0
- package/dist/web/server.js +366 -0
- package/dist/web/session-state.js +39 -0
- package/docs/deploy.md +307 -0
- package/package.json +55 -0
- package/skills/pier-boards/SKILL.md +210 -0
- package/skills/pier-slack/SKILL.md +135 -0
- package/skills/pier-tasks/SKILL.md +120 -0
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
// Telegram's half of the settings panel: HTML markup, an inline keyboard, and
|
|
2
|
+
// a forced reply for the one typed answer. The panel itself lives in
|
|
3
|
+
// `panel.ts`.
|
|
4
|
+
//
|
|
5
|
+
// Asking for a working directory costs a Map here: a forced reply arrives as
|
|
6
|
+
// an ordinary message, so the prompt's message id has to be remembered to
|
|
7
|
+
// recognize the answer. Slack's modal carries that context itself.
|
|
8
|
+
import { ChatPanel, PANEL_PREFIX, } from "./panel.js";
|
|
9
|
+
import { escapeHtml as esc } from "./telegram-render.js";
|
|
10
|
+
const button = (b) => ({ text: b.label, callback_data: `${PANEL_PREFIX}${b.action}` });
|
|
11
|
+
export class TelegramPanel extends ChatPanel {
|
|
12
|
+
deps;
|
|
13
|
+
platform = "telegram";
|
|
14
|
+
fence = ["<code>", "</code>"];
|
|
15
|
+
/** Conversations waiting for a typed working directory (ForceReply). */
|
|
16
|
+
cwdPrompts = new Map();
|
|
17
|
+
constructor(deps) {
|
|
18
|
+
super(deps);
|
|
19
|
+
this.deps = deps;
|
|
20
|
+
}
|
|
21
|
+
esc(text) {
|
|
22
|
+
return esc(text);
|
|
23
|
+
}
|
|
24
|
+
/** Topics are a Telegram-only gate, and only on a forum. */
|
|
25
|
+
gateExtras(chat, policy) {
|
|
26
|
+
return chat.kind === "forum" ? ` · topics ${policy.topicMode ? "on" : "off"}` : "";
|
|
27
|
+
}
|
|
28
|
+
// --- rendering ---------------------------------------------------------------
|
|
29
|
+
text(view, note) {
|
|
30
|
+
const body = view.groups
|
|
31
|
+
.map((g) => [`<b>${g.title}</b>${g.suffix ?? ""}`, ...g.lines].join("\n"))
|
|
32
|
+
.join("\n\n");
|
|
33
|
+
return note ? `${body}\n\n<i>${esc(note)}</i>` : body;
|
|
34
|
+
}
|
|
35
|
+
/** A long model id does not share a row with anything. */
|
|
36
|
+
keyboard(view) {
|
|
37
|
+
return {
|
|
38
|
+
inline_keyboard: [
|
|
39
|
+
...(view.picks ?? []).map((pick) => [button(pick)]),
|
|
40
|
+
...view.rows.map((row) => row.map(button)),
|
|
41
|
+
],
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
/** Open a fresh panel, replacing whichever one this conversation had. */
|
|
45
|
+
async open(key, chatId, topicId) {
|
|
46
|
+
const view = await this.view(key, chatId);
|
|
47
|
+
const sent = await this.deps.api.sendMessage({
|
|
48
|
+
chat_id: chatId,
|
|
49
|
+
message_thread_id: topicId,
|
|
50
|
+
text: this.text(view),
|
|
51
|
+
parse_mode: "HTML",
|
|
52
|
+
reply_markup: this.keyboard(view),
|
|
53
|
+
});
|
|
54
|
+
this.remember(key, { chatId, topicId, messageId: sent.message_id, models: [] });
|
|
55
|
+
}
|
|
56
|
+
async draw(state, view, note) {
|
|
57
|
+
await this.deps.api.editMessage({
|
|
58
|
+
chat_id: state.chatId,
|
|
59
|
+
message_id: state.messageId,
|
|
60
|
+
text: this.text(view, note),
|
|
61
|
+
parse_mode: "HTML",
|
|
62
|
+
reply_markup: this.keyboard(view),
|
|
63
|
+
}).catch((err) => this.deps.log(`panel edit failed: ${String(err)}`));
|
|
64
|
+
}
|
|
65
|
+
async erase(state) {
|
|
66
|
+
await this.deps.api.deleteMessage(state.chatId, state.messageId)
|
|
67
|
+
.catch((err) => this.deps.log(`panel close failed: ${String(err)}`));
|
|
68
|
+
}
|
|
69
|
+
// --- actions -----------------------------------------------------------------
|
|
70
|
+
/**
|
|
71
|
+
* Handle a `cfg:` button. Returns false when the payload is not ours, so the
|
|
72
|
+
* caller can treat it as one of the agent's next-step labels instead.
|
|
73
|
+
*/
|
|
74
|
+
async onCallback(query, key) {
|
|
75
|
+
return this.dispatch(key, query.data ?? "", undefined, async () => {
|
|
76
|
+
const message = query.message;
|
|
77
|
+
if (message)
|
|
78
|
+
await this.open(key, String(message.chat.id), message.message_thread_id);
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
// --- working directory (one typed answer) ------------------------------------
|
|
82
|
+
async promptCwd(key, state) {
|
|
83
|
+
const sent = await this.deps.api.sendMessage({
|
|
84
|
+
chat_id: state.chatId,
|
|
85
|
+
message_thread_id: state.topicId,
|
|
86
|
+
// Said plainly: this is not an edit, it is a new session.
|
|
87
|
+
text: "Reply with an absolute path. A new session starts there; the current one stays in its own directory.",
|
|
88
|
+
reply_markup: { force_reply: true, input_field_placeholder: "/path/to/project" },
|
|
89
|
+
});
|
|
90
|
+
this.cwdPrompts.set(key.conversationId, sent.message_id);
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* Consume a reply to the working-directory prompt. Returns true when this
|
|
94
|
+
* message was that answer and must not reach the agent.
|
|
95
|
+
*/
|
|
96
|
+
async consumeCwdReply(msg, key) {
|
|
97
|
+
const pending = this.cwdPrompts.get(key.conversationId);
|
|
98
|
+
if (!pending || msg.reply_to_message?.message_id !== pending)
|
|
99
|
+
return false;
|
|
100
|
+
this.cwdPrompts.delete(key.conversationId);
|
|
101
|
+
const path = (msg.text ?? "").trim();
|
|
102
|
+
const started = await this.startSessionIn(key, path);
|
|
103
|
+
// The answer was typed in the chat, so the outcome is said in the chat:
|
|
104
|
+
// a panel note alone would be easy to miss under one's own message.
|
|
105
|
+
await this.deps.api.sendMessage({
|
|
106
|
+
chat_id: msg.chat.id,
|
|
107
|
+
message_thread_id: msg.message_thread_id,
|
|
108
|
+
text: "error" in started ? started.error : `New session in <code>${esc(path)}</code>.`,
|
|
109
|
+
parse_mode: "HTML",
|
|
110
|
+
});
|
|
111
|
+
return true;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { chunkText } from "./chunk.js";
|
|
2
|
+
// How a reply looks on Telegram: text and buttons.
|
|
3
|
+
//
|
|
4
|
+
// Markdown → Telegram Bot API HTML. Telegram's parser accepts a tiny tag set
|
|
5
|
+
// and rejects the whole message on anything else, so we escape first and
|
|
6
|
+
// reintroduce exactly the tags it documents: b, i, s, code, pre, a.
|
|
7
|
+
// Unsupported markdown (tables, images, nested lists) degrades to plain text
|
|
8
|
+
// rather than losing the message.
|
|
9
|
+
const MAX_CHARS = 3800; // Telegram's hard limit is 4096; leave room for tags.
|
|
10
|
+
/** Shared: the adapter and the panel escape plain text with this too. */
|
|
11
|
+
export const escapeHtml = (s) => s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
12
|
+
/** Inline emphasis, applied after escaping so `<b>` can only come from us. */
|
|
13
|
+
function inline(text) {
|
|
14
|
+
return text
|
|
15
|
+
// Links first: their label may itself carry emphasis.
|
|
16
|
+
.replace(/\[([^\]\n]+)\]\((https?:\/\/[^\s)]+)\)/g, (_m, label, url) => `<a href="${url.replace(/"/g, """)}">${label}</a>`)
|
|
17
|
+
.replace(/\*\*([^\n*]+)\*\*/g, "<b>$1</b>")
|
|
18
|
+
.replace(/~~([^\n~]+)~~/g, "<s>$1</s>")
|
|
19
|
+
.replace(/(^|[\s(])[*_]([^\n*_]+)[*_](?=[\s).,!?:;]|$)/g, "$1<i>$2</i>")
|
|
20
|
+
// Headings carry no size in Telegram; bold is the closest honest render.
|
|
21
|
+
.replace(/^#{1,6}[ \t]+(.+)$/gm, "<b>$1</b>");
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Render one assistant turn. Code spans and fences are extracted before
|
|
25
|
+
* escaping so emphasis inside them stays literal.
|
|
26
|
+
*/
|
|
27
|
+
export function toTelegramHtml(markdown) {
|
|
28
|
+
const stash = [];
|
|
29
|
+
// Private-use sentinels: markdown can't contain them, so a stashed block
|
|
30
|
+
// can't be re-matched by the escaping and emphasis passes that follow.
|
|
31
|
+
const keep = (html) => `\uE000${stash.push(html) - 1}\uE001`;
|
|
32
|
+
let out = markdown.replace(/```([\w.+-]*)\n?([\s\S]*?)```/g, (_m, lang, code) => keep(lang
|
|
33
|
+
? `<pre><code class="language-${escapeHtml(lang)}">${escapeHtml(code.replace(/\n+$/, ""))}</code></pre>`
|
|
34
|
+
: `<pre>${escapeHtml(code.replace(/\n+$/, ""))}</pre>`));
|
|
35
|
+
out = out.replace(/`([^`\n]+)`/g, (_m, code) => keep(`<code>${escapeHtml(code)}</code>`));
|
|
36
|
+
out = inline(escapeHtml(out));
|
|
37
|
+
return out.replace(/\uE000(\d+)\uE001/g, (_m, i) => stash[Number(i)] ?? "");
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Split rendered HTML into sendable chunks. A turn long enough to need this can
|
|
41
|
+
* still be cut inside a <pre> block; Telegram closes the tag on its own and the
|
|
42
|
+
* text survives, which beats dropping the turn. (Slack cannot do that, which is
|
|
43
|
+
* why its renderer re-balances fences after the cut.)
|
|
44
|
+
*/
|
|
45
|
+
export const chunk = (html) => chunkText(html, MAX_CHARS);
|
|
46
|
+
// --- next-step buttons -------------------------------------------------------
|
|
47
|
+
/**
|
|
48
|
+
* Telegram caps `callback_data` at 64 *bytes* — about 21 CJK characters, far
|
|
49
|
+
* too little to carry a label. Buttons send an index instead, and the label is
|
|
50
|
+
* read back off the message's own keyboard.
|
|
51
|
+
*/
|
|
52
|
+
export const OFFER_PREFIX = "sg:";
|
|
53
|
+
// Buttons in one row share the row's width, so packing is budgeted by display
|
|
54
|
+
// width rather than count: a CJK glyph takes about twice an ASCII one.
|
|
55
|
+
const ROW_WIDTH = 26;
|
|
56
|
+
const ROW_BUTTONS = 3;
|
|
57
|
+
/** Rough rendered width: CJK, fullwidth punctuation and emoji take two cells. */
|
|
58
|
+
const displayWidth = (label) => [...label].reduce((n, ch) => n + ((ch.codePointAt(0) ?? 0) > 0x2e7f ? 2 : 1), 0);
|
|
59
|
+
/**
|
|
60
|
+
* Pack short labels onto shared rows, keeping the offered order. A row that is
|
|
61
|
+
* already wide stops accepting: buttons in one Telegram row split the width
|
|
62
|
+
* evenly, so squeezing a long label in truncates everything beside it.
|
|
63
|
+
*/
|
|
64
|
+
export function keyboard(labels) {
|
|
65
|
+
if (!labels.length)
|
|
66
|
+
return undefined;
|
|
67
|
+
const rows = [];
|
|
68
|
+
let used = 0;
|
|
69
|
+
labels.forEach((label, index) => {
|
|
70
|
+
const width = displayWidth(label);
|
|
71
|
+
const row = rows.at(-1);
|
|
72
|
+
const button = { text: label, callback_data: `${OFFER_PREFIX}${index}` };
|
|
73
|
+
if (!row || row.length >= ROW_BUTTONS || used + width > ROW_WIDTH) {
|
|
74
|
+
rows.push([button]);
|
|
75
|
+
used = width;
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
row.push(button);
|
|
79
|
+
used += width;
|
|
80
|
+
});
|
|
81
|
+
return { inline_keyboard: rows };
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* The label a next-step payload stands for. Read off the tapped message's own
|
|
85
|
+
* keyboard, which Telegram echoes back — so a button keeps working across a
|
|
86
|
+
* restart or a config reload, where an in-memory offer list would not.
|
|
87
|
+
* A payload we never wrote (an older label-as-payload button) passes through.
|
|
88
|
+
*/
|
|
89
|
+
export function offeredLabel(msg, data) {
|
|
90
|
+
if (!data.startsWith(OFFER_PREFIX))
|
|
91
|
+
return data;
|
|
92
|
+
return msg.reply_markup?.inline_keyboard
|
|
93
|
+
.flat()
|
|
94
|
+
.find((button) => button.callback_data === data)
|
|
95
|
+
?.text;
|
|
96
|
+
}
|
|
@@ -0,0 +1,473 @@
|
|
|
1
|
+
// Telegram adapter: normalize inbound updates, render outbound turns.
|
|
2
|
+
//
|
|
3
|
+
// Three behaviours are Telegram-specific and live only here:
|
|
4
|
+
// - Topic mode. A message that lands in a forum group's General opens a fresh
|
|
5
|
+
// topic named after its first line, and the conversation (hence the Pi
|
|
6
|
+
// session) is that topic. One group therefore hosts many parallel sessions.
|
|
7
|
+
// - Reaction receipts. Intermediate reasoning is never posted to IM; instead
|
|
8
|
+
// every message that entered a turn wears 👀 until the turn settles, so a
|
|
9
|
+
// steered message gets feedback without a line in the chat.
|
|
10
|
+
// - Bind. `/bind <code>` in a DM redeems a Console-issued code.
|
|
11
|
+
//
|
|
12
|
+
// Everything policy-shaped (mention/bind gates, per-chat overrides) is in
|
|
13
|
+
// config.ts, platform-blind and shared with the adapters still to come.
|
|
14
|
+
import { formatTurnMeta, originLabel } from "../core/reply.js";
|
|
15
|
+
import { logger } from "../log.js";
|
|
16
|
+
import { Chains } from "./chains.js";
|
|
17
|
+
import { parseCommand } from "./commands.js";
|
|
18
|
+
import { Gatekeeper } from "./gatekeeper.js";
|
|
19
|
+
import { ReceiptLedger, Receipts } from "./receipts.js";
|
|
20
|
+
import { TelegramPanel } from "./telegram-panel.js";
|
|
21
|
+
import { chunk, escapeHtml, keyboard, offeredLabel, toTelegramHtml } from "./telegram-render.js";
|
|
22
|
+
import { TelegramApi, } from "./telegram-api.js";
|
|
23
|
+
const WORKING = "👀";
|
|
24
|
+
const POLL_SECONDS = 30;
|
|
25
|
+
// Backpressure: how many chats may be mid-handling before the poll loop waits.
|
|
26
|
+
// Bounds memory without ever advancing the offset past what we accepted.
|
|
27
|
+
const MAX_ACTIVE_CHATS = 16;
|
|
28
|
+
// Longest a 👀 may sit before we assume its turn will never settle (a dispatch
|
|
29
|
+
// that failed, a session that died). Generous: a real coding turn can be long.
|
|
30
|
+
const RECEIPT_STALE_MS = 30 * 60_000;
|
|
31
|
+
// Longest stop() waits for in-flight handlers before letting reload() proceed.
|
|
32
|
+
const DRAIN_TIMEOUT_MS = 5000;
|
|
33
|
+
const TOPIC_TITLE_MAX = 60;
|
|
34
|
+
/**
|
|
35
|
+
* A forum conversation is `<chatId>/<topicId>`; anything else is `<chatId>`.
|
|
36
|
+
* This pair is the only definition of the format — control.ts and the panel
|
|
37
|
+
* decode with it rather than splitting on "/" themselves.
|
|
38
|
+
*/
|
|
39
|
+
const conversationId = (chatId, topicId) => topicId ? `${chatId}/${topicId}` : String(chatId);
|
|
40
|
+
export const parseConversation = (id) => {
|
|
41
|
+
const [chatId = "", topic] = id.split("/");
|
|
42
|
+
const topicId = topic ? Number(topic) : undefined;
|
|
43
|
+
return { chatId, topicId: Number.isSafeInteger(topicId) ? topicId : undefined };
|
|
44
|
+
};
|
|
45
|
+
/** Telegram numbers General as topic 1 and omits the id on plain groups. */
|
|
46
|
+
const inGeneral = (msg) => !msg.message_thread_id || msg.message_thread_id === 1;
|
|
47
|
+
function topicTitle(text) {
|
|
48
|
+
const line = (text.split("\n").find((l) => l.trim()) ?? "").trim();
|
|
49
|
+
if (!line)
|
|
50
|
+
return `Session ${new Date().toISOString().slice(5, 16).replace("T", " ")}`;
|
|
51
|
+
return line.length > TOPIC_TITLE_MAX ? `${line.slice(0, TOPIC_TITLE_MAX - 3).trimEnd()}...` : line;
|
|
52
|
+
}
|
|
53
|
+
export class TelegramChannel {
|
|
54
|
+
deps;
|
|
55
|
+
id = "telegram";
|
|
56
|
+
api;
|
|
57
|
+
log;
|
|
58
|
+
/** 👀 lifecycle, durable; see receipts.ts for why it is not just a Map. */
|
|
59
|
+
receipts;
|
|
60
|
+
/** Ordering per chat, concurrency across them; see chains.ts. */
|
|
61
|
+
chains;
|
|
62
|
+
/** The inbound gate and the bind-hint throttle; see gatekeeper.ts. */
|
|
63
|
+
gate;
|
|
64
|
+
/** The in-chat settings panel; absent when no control was wired (tests). */
|
|
65
|
+
panel;
|
|
66
|
+
me;
|
|
67
|
+
offset;
|
|
68
|
+
running = false;
|
|
69
|
+
constructor(deps) {
|
|
70
|
+
this.deps = deps;
|
|
71
|
+
this.api = deps.client ?? new TelegramApi(deps.store.get("telegram").token);
|
|
72
|
+
this.log = deps.log ?? ((m) => logger("telegram").warn(m));
|
|
73
|
+
// No cap here: the poll loop applies backpressure itself, before advancing
|
|
74
|
+
// the ack cursor past an update it has not accepted.
|
|
75
|
+
this.chains = new Chains(this.log);
|
|
76
|
+
this.gate = new Gatekeeper(deps.store, "telegram", this.log);
|
|
77
|
+
this.receipts = new Receipts(
|
|
78
|
+
// The ledger keeps message ids as opaque strings (a Slack ts is not a
|
|
79
|
+
// number); Telegram's own are numeric, so the cast happens right here at
|
|
80
|
+
// the API boundary rather than leaking a platform's id type into shared code.
|
|
81
|
+
{ setReaction: (chatId, messageId, emoji) => this.api.setReaction(chatId, Number(messageId), emoji) }, deps.receipts ?? new ReceiptLedger("telegram"), this.log, WORKING, RECEIPT_STALE_MS);
|
|
82
|
+
if (deps.control) {
|
|
83
|
+
this.panel = new TelegramPanel({
|
|
84
|
+
api: this.api,
|
|
85
|
+
control: deps.control,
|
|
86
|
+
store: deps.store,
|
|
87
|
+
log: this.log,
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
async start(onMessage) {
|
|
92
|
+
const me = await this.api.getMe();
|
|
93
|
+
this.me = { id: me.id, username: me.username ?? "" };
|
|
94
|
+
if (!this.me.username) {
|
|
95
|
+
// Without a handle, "was I mentioned?" can only ever answer no, so every
|
|
96
|
+
// group with require-mention on goes silent. Loud, not a debug line.
|
|
97
|
+
this.log("bot has no username: mention detection is disabled");
|
|
98
|
+
}
|
|
99
|
+
this.running = true;
|
|
100
|
+
// Best-effort and off the critical path.
|
|
101
|
+
void this.receipts.sweep(true);
|
|
102
|
+
void this.poll(onMessage);
|
|
103
|
+
}
|
|
104
|
+
async stop() {
|
|
105
|
+
this.running = false;
|
|
106
|
+
// Let in-flight updates finish: reload() starts a replacement right after,
|
|
107
|
+
// and two adapters handling one message would prompt the session twice.
|
|
108
|
+
// Bounded, because reload() runs on the Console's save request — a stuck
|
|
109
|
+
// handler must not hold that open.
|
|
110
|
+
await this.chains.drain(DRAIN_TIMEOUT_MS);
|
|
111
|
+
}
|
|
112
|
+
// --- inbound ---------------------------------------------------------------
|
|
113
|
+
async poll(onMessage) {
|
|
114
|
+
while (this.running) {
|
|
115
|
+
try {
|
|
116
|
+
// Floor on an empty round trip: getUpdates is supposed to block for
|
|
117
|
+
// POLL_SECONDS, and a proxy that answers instantly would otherwise
|
|
118
|
+
// turn this into a hot loop.
|
|
119
|
+
const startedAt = Date.now();
|
|
120
|
+
const updates = await this.api.getUpdates(this.offset, POLL_SECONDS);
|
|
121
|
+
if (!this.running)
|
|
122
|
+
return;
|
|
123
|
+
void this.receipts.sweep();
|
|
124
|
+
if (!updates.length && Date.now() - startedAt < 1000) {
|
|
125
|
+
await new Promise((r) => setTimeout(r, 1000));
|
|
126
|
+
}
|
|
127
|
+
for (const update of updates) {
|
|
128
|
+
if (!this.running)
|
|
129
|
+
return;
|
|
130
|
+
while (this.chains.size >= MAX_ACTIVE_CHATS)
|
|
131
|
+
await this.chains.oldest();
|
|
132
|
+
this.offset = update.update_id + 1;
|
|
133
|
+
const chat = update.message?.chat.id ?? update.callback_query?.message?.chat.id;
|
|
134
|
+
if (chat === undefined)
|
|
135
|
+
continue; // malformed: no chat to answer in
|
|
136
|
+
this.chains.run(String(chat), async () => {
|
|
137
|
+
try {
|
|
138
|
+
if (update.callback_query)
|
|
139
|
+
await this.onCallback(update.callback_query, onMessage);
|
|
140
|
+
else if (update.message)
|
|
141
|
+
await this.onMessage(update.message, onMessage);
|
|
142
|
+
}
|
|
143
|
+
catch (err) {
|
|
144
|
+
this.log(`update ${update.update_id} dropped: ${String(err)}`);
|
|
145
|
+
}
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
catch (err) {
|
|
150
|
+
if (!this.running)
|
|
151
|
+
return;
|
|
152
|
+
this.log(`poll failed, retrying: ${String(err)}`);
|
|
153
|
+
await new Promise((r) => setTimeout(r, 3000));
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
async onMessage(msg, onMessage) {
|
|
158
|
+
if (!msg.from || msg.from.id === this.me?.id)
|
|
159
|
+
return; // own echo, or malformed
|
|
160
|
+
const raw = (msg.text ?? msg.caption ?? "").trim();
|
|
161
|
+
if (!raw && !msg.photo?.length)
|
|
162
|
+
return;
|
|
163
|
+
const chatId = String(msg.chat.id);
|
|
164
|
+
const isDm = msg.chat.type === "private";
|
|
165
|
+
const kind = isDm ? "dm" : msg.chat.is_forum ? "forum" : "group";
|
|
166
|
+
const name = msg.chat.title ?? [msg.from.first_name, msg.from.last_name].filter(Boolean).join(" ");
|
|
167
|
+
this.deps.store.discoverChat("telegram", { id: chatId, name: name || chatId, kind });
|
|
168
|
+
const text = this.stripMention(raw, msg);
|
|
169
|
+
const command = parseCommand(text);
|
|
170
|
+
// A command aimed at another bot in the same group is not ours to answer.
|
|
171
|
+
const mine = !command?.target || command.target.toLowerCase() === this.me?.username.toLowerCase();
|
|
172
|
+
const bindRequest = mine && command?.name === "bind" && isDm;
|
|
173
|
+
const admitted = this.gate.admit("message", chatId, {
|
|
174
|
+
isDm,
|
|
175
|
+
addressed: this.addressed(raw, msg),
|
|
176
|
+
userId: String(msg.from.id),
|
|
177
|
+
bindRequest,
|
|
178
|
+
});
|
|
179
|
+
if (!admitted) {
|
|
180
|
+
if (isDm)
|
|
181
|
+
await this.hintBind(msg);
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
if (bindRequest)
|
|
185
|
+
return this.bind(msg, command?.args ?? "");
|
|
186
|
+
if (mine && command?.name === "stop")
|
|
187
|
+
return this.abortTurn(msg);
|
|
188
|
+
const here = {
|
|
189
|
+
channelId: this.id,
|
|
190
|
+
conversationId: conversationId(chatId, msg.message_thread_id),
|
|
191
|
+
};
|
|
192
|
+
// A typed answer to the panel's directory prompt, not a prompt for the agent.
|
|
193
|
+
if (await this.panel?.consumeCwdReply(msg, here))
|
|
194
|
+
return;
|
|
195
|
+
// `@bot` on its own (text is empty once the mention is stripped) and
|
|
196
|
+
// `/settings` are the same request: show me this conversation's settings.
|
|
197
|
+
if (this.panel && mine && (command?.name === "settings" || (!text && !msg.photo?.length))) {
|
|
198
|
+
return this.panel.open(here, chatId, msg.message_thread_id);
|
|
199
|
+
}
|
|
200
|
+
// Downloading only past the gate: an unauthorized sender must not be able
|
|
201
|
+
// to make the bot pull bytes on their behalf.
|
|
202
|
+
const images = await this.photos(msg);
|
|
203
|
+
const topicId = await this.routeTopic(msg, text);
|
|
204
|
+
const key = { channelId: this.id, conversationId: conversationId(chatId, topicId) };
|
|
205
|
+
this.receipts.mark(key.conversationId, chatId, String(msg.message_id));
|
|
206
|
+
// IM messages steer by default: a follow-up that waits for the turn to end
|
|
207
|
+
// is the wrong default when the human is watching a 👀 in a chat window.
|
|
208
|
+
onMessage({
|
|
209
|
+
key,
|
|
210
|
+
senderId: String(msg.from.id),
|
|
211
|
+
// A group is many people talking into one session; the update already
|
|
212
|
+
// carries the name, so no lookup is needed here.
|
|
213
|
+
sender: { id: String(msg.from.id), name: senderName(msg.from) },
|
|
214
|
+
text,
|
|
215
|
+
images,
|
|
216
|
+
mode: "steer",
|
|
217
|
+
});
|
|
218
|
+
}
|
|
219
|
+
/** Quick-reply buttons send their own label back as an ordinary message. */
|
|
220
|
+
async onCallback(query, onMessage) {
|
|
221
|
+
await this.api.answerCallbackQuery(query.id).catch(() => { });
|
|
222
|
+
const msg = query.message;
|
|
223
|
+
if (!msg || !query.data)
|
|
224
|
+
return;
|
|
225
|
+
const chatId = String(msg.chat.id);
|
|
226
|
+
const admitted = this.gate.admit("callback", chatId, {
|
|
227
|
+
isDm: msg.chat.type === "private",
|
|
228
|
+
addressed: true, // pressing the bot's own button is addressing it
|
|
229
|
+
userId: String(query.from.id),
|
|
230
|
+
});
|
|
231
|
+
if (!admitted)
|
|
232
|
+
return;
|
|
233
|
+
const key = {
|
|
234
|
+
channelId: this.id,
|
|
235
|
+
conversationId: conversationId(chatId, msg.message_thread_id),
|
|
236
|
+
};
|
|
237
|
+
// Panel taps are namespaced `cfg:` and never reach the agent.
|
|
238
|
+
if (await this.panel?.onCallback(query, key))
|
|
239
|
+
return;
|
|
240
|
+
const text = offeredLabel(msg, query.data);
|
|
241
|
+
if (text === undefined) {
|
|
242
|
+
await this.api.answerCallbackQuery(query.id, "That option is no longer on this message.")
|
|
243
|
+
.catch(() => { });
|
|
244
|
+
return;
|
|
245
|
+
}
|
|
246
|
+
// The options belonged to the turn that just ended; once one is taken the
|
|
247
|
+
// rest answer a question the conversation has moved past (the web drops
|
|
248
|
+
// them for the same reason).
|
|
249
|
+
await this.api.clearKeyboard(chatId, msg.message_id).catch(() => { });
|
|
250
|
+
// A bot cannot post as the user, so the pick is echoed and marked as one.
|
|
251
|
+
// Without it the chat shows an answer to a request nobody can see being
|
|
252
|
+
// made, and there is no message of the user's to carry the eyes.
|
|
253
|
+
//
|
|
254
|
+
// No reply quote: the marker already says what this is, and quoting a long
|
|
255
|
+
// answer to show which of its buttons was tapped costs more space than it
|
|
256
|
+
// explains.
|
|
257
|
+
const echo = await this.api.sendMessage({
|
|
258
|
+
chat_id: chatId,
|
|
259
|
+
message_thread_id: msg.message_thread_id,
|
|
260
|
+
text: `\u25b8 ${escapeHtml(text)}`,
|
|
261
|
+
parse_mode: "HTML",
|
|
262
|
+
}).catch((err) => {
|
|
263
|
+
this.log(`option echo failed: ${String(err)}`);
|
|
264
|
+
return undefined;
|
|
265
|
+
});
|
|
266
|
+
// The receipt goes on the echo, not on the bot message that held the
|
|
267
|
+
// buttons: the eyes mean "this input is being worked on".
|
|
268
|
+
if (echo)
|
|
269
|
+
this.receipts.mark(key.conversationId, chatId, String(echo.message_id));
|
|
270
|
+
onMessage({ key, senderId: String(query.from.id), text, mode: "steer" });
|
|
271
|
+
}
|
|
272
|
+
/**
|
|
273
|
+
* Stop the turn this conversation is running. The abort makes Pi end the
|
|
274
|
+
* turn, which reaches send() through the normal turn-end path and clears the
|
|
275
|
+
* 👀 receipts — so nothing here touches them.
|
|
276
|
+
*/
|
|
277
|
+
async abortTurn(msg) {
|
|
278
|
+
const key = {
|
|
279
|
+
channelId: this.id,
|
|
280
|
+
conversationId: conversationId(msg.chat.id, msg.message_thread_id),
|
|
281
|
+
};
|
|
282
|
+
await this.deps.control?.abort(key);
|
|
283
|
+
await this.api.sendMessage({
|
|
284
|
+
chat_id: msg.chat.id,
|
|
285
|
+
message_thread_id: msg.message_thread_id,
|
|
286
|
+
text: "⏹ Stopped.",
|
|
287
|
+
});
|
|
288
|
+
}
|
|
289
|
+
/**
|
|
290
|
+
* Tell an unbound DM sender what to do. Groups stay silent (see gate()), but
|
|
291
|
+
* a DM that swallows every message looks broken rather than locked.
|
|
292
|
+
*/
|
|
293
|
+
async hintBind(msg) {
|
|
294
|
+
if (!this.gate.mayHint(String(msg.from?.id ?? "")))
|
|
295
|
+
return;
|
|
296
|
+
await this.api.sendMessage({
|
|
297
|
+
chat_id: msg.chat.id,
|
|
298
|
+
text: "You are not bound yet. Ask the operator for a bind code, then send /bind <code>.",
|
|
299
|
+
}).catch((err) => this.log(`bind hint failed: ${String(err)}`));
|
|
300
|
+
}
|
|
301
|
+
async bind(msg, code) {
|
|
302
|
+
const user = msg.from;
|
|
303
|
+
const name = senderName(user);
|
|
304
|
+
const ok = this.deps.store.redeemBindCode("telegram", code, { id: String(user.id), name });
|
|
305
|
+
await this.api.sendMessage({
|
|
306
|
+
chat_id: msg.chat.id,
|
|
307
|
+
text: ok ? `Bound as ${name}.` : "That bind code is invalid or expired.",
|
|
308
|
+
});
|
|
309
|
+
}
|
|
310
|
+
/**
|
|
311
|
+
* Topic mode: a message arriving in a forum group's General starts a new
|
|
312
|
+
* topic, so every request gets its own thread and its own Pi session. A
|
|
313
|
+
* reply or a slash command stays put — it is continuing something, not
|
|
314
|
+
* starting it. Failure falls back to the current thread rather than losing
|
|
315
|
+
* the message.
|
|
316
|
+
*/
|
|
317
|
+
async routeTopic(msg, text) {
|
|
318
|
+
// Every reason to decline is named and logged: "why did it not open a
|
|
319
|
+
// topic" is the question this feature will be asked forever, and silence
|
|
320
|
+
// makes six invisible conditions indistinguishable from a bug.
|
|
321
|
+
const decline = !this.deps.store.policy("telegram", String(msg.chat.id)).topicMode
|
|
322
|
+
? "topic mode off for this chat"
|
|
323
|
+
: msg.chat.type !== "supergroup"
|
|
324
|
+
? `chat is a ${msg.chat.type}, not a supergroup`
|
|
325
|
+
: !msg.chat.is_forum
|
|
326
|
+
? "group has Topics disabled in Telegram"
|
|
327
|
+
: !inGeneral(msg)
|
|
328
|
+
? `already inside topic ${msg.message_thread_id}`
|
|
329
|
+
: msg.reply_to_message
|
|
330
|
+
? "message is a reply, so it continues an existing thread"
|
|
331
|
+
: text.startsWith("/")
|
|
332
|
+
? "message is a command"
|
|
333
|
+
: "";
|
|
334
|
+
if (decline) {
|
|
335
|
+
this.log(`no new topic in chat ${msg.chat.id}: ${decline}`);
|
|
336
|
+
return msg.message_thread_id;
|
|
337
|
+
}
|
|
338
|
+
const title = topicTitle(text);
|
|
339
|
+
try {
|
|
340
|
+
const topic = await this.api.createForumTopic(msg.chat.id, title);
|
|
341
|
+
// The whole point of the notice is to get out of General, so the title is
|
|
342
|
+
// the link into the new topic rather than decoration.
|
|
343
|
+
await this.api.sendMessage({
|
|
344
|
+
chat_id: msg.chat.id,
|
|
345
|
+
text: `→ <a href="${topicLink(msg.chat, topic.message_thread_id)}">${escapeHtml(title)}</a>`,
|
|
346
|
+
parse_mode: "HTML",
|
|
347
|
+
message_thread_id: msg.message_thread_id,
|
|
348
|
+
reply_to_message_id: msg.message_id,
|
|
349
|
+
}).catch(() => { });
|
|
350
|
+
return topic.message_thread_id;
|
|
351
|
+
}
|
|
352
|
+
catch (err) {
|
|
353
|
+
this.log(`topic creation failed, staying in General: ${String(err)}`);
|
|
354
|
+
return msg.message_thread_id;
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
async photos(msg) {
|
|
358
|
+
const largest = msg.photo?.at(-1);
|
|
359
|
+
if (!largest)
|
|
360
|
+
return [];
|
|
361
|
+
try {
|
|
362
|
+
return [await this.api.downloadPhoto(largest.file_id)];
|
|
363
|
+
}
|
|
364
|
+
catch (err) {
|
|
365
|
+
this.log(`photo download failed: ${String(err)}`);
|
|
366
|
+
return [];
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
// --- addressing ------------------------------------------------------------
|
|
370
|
+
/** Mentioned, replying to the bot, or a slash command aimed at this bot. */
|
|
371
|
+
addressed(text, msg) {
|
|
372
|
+
if (msg.reply_to_message?.from?.id === this.me?.id)
|
|
373
|
+
return true;
|
|
374
|
+
if (text.startsWith("/")) {
|
|
375
|
+
const target = /^\/\S+?@(\S+)/.exec(text)?.[1];
|
|
376
|
+
return !target || target.toLowerCase() === this.me?.username.toLowerCase();
|
|
377
|
+
}
|
|
378
|
+
const handle = `@${this.me?.username.toLowerCase()}`;
|
|
379
|
+
return !!this.me?.username && text.toLowerCase().includes(handle);
|
|
380
|
+
}
|
|
381
|
+
/** A leading @bot is addressing, not content — the agent should not see it. */
|
|
382
|
+
stripMention(text, msg) {
|
|
383
|
+
const handle = `@${this.me?.username ?? ""}`;
|
|
384
|
+
if (!this.me?.username)
|
|
385
|
+
return text;
|
|
386
|
+
const mention = msg.entities?.find((e) => e.type === "mention" && e.offset === 0);
|
|
387
|
+
if (mention && text.slice(0, mention.length).toLowerCase() === handle.toLowerCase()) {
|
|
388
|
+
return text.slice(mention.length).replace(/^[\s,:-]+/, "");
|
|
389
|
+
}
|
|
390
|
+
return text.toLowerCase().startsWith(handle.toLowerCase())
|
|
391
|
+
? text.slice(handle.length).replace(/^[\s,:-]+/, "")
|
|
392
|
+
: text;
|
|
393
|
+
}
|
|
394
|
+
// --- outbound --------------------------------------------------------------
|
|
395
|
+
/**
|
|
396
|
+
* Called on every turn-end, empty text included: the turn settled with
|
|
397
|
+
* nothing to say, and the 👀 receipts still have to come off.
|
|
398
|
+
*/
|
|
399
|
+
async send(conversation, reply) {
|
|
400
|
+
const { chatId, topicId } = parseConversation(conversation);
|
|
401
|
+
const text = reply.text.trim();
|
|
402
|
+
// A turn that produced no text still posts its footer, and says which kind
|
|
403
|
+
// of nothing it was: total silence is indistinguishable from a crash, and
|
|
404
|
+
// the person waiting cannot tell. See AGENTS.md — an empty turn is still an
|
|
405
|
+
// event, and an event nobody can see is not observable.
|
|
406
|
+
const buttons = keyboard(reply.suggestions);
|
|
407
|
+
// Options count as a reply: the buttons are the answer.
|
|
408
|
+
const quiet = text || buttons
|
|
409
|
+
? ""
|
|
410
|
+
: reply.silence
|
|
411
|
+
? `<i>stayed silent \u2014 ${escapeHtml(reply.silence)}</i>`
|
|
412
|
+
: "<i>no reply</i>";
|
|
413
|
+
const body = (text ? toTelegramHtml(text) : quiet) + turnFooter(reply.meta);
|
|
414
|
+
try {
|
|
415
|
+
if (body.trim()) {
|
|
416
|
+
const parts = chunk(body);
|
|
417
|
+
for (const [i, part] of parts.entries()) {
|
|
418
|
+
await this.api.sendMessage({
|
|
419
|
+
chat_id: chatId,
|
|
420
|
+
message_thread_id: topicId,
|
|
421
|
+
text: part,
|
|
422
|
+
parse_mode: "HTML",
|
|
423
|
+
// Next-step buttons ride the last chunk; a click sends the label.
|
|
424
|
+
reply_markup: i === parts.length - 1 ? buttons : undefined,
|
|
425
|
+
});
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
finally {
|
|
430
|
+
// Always: a 👀 left up because the reply failed would sit there until the
|
|
431
|
+
// stale sweep, looking like the agent is still working.
|
|
432
|
+
await this.receipts.settle(conversation);
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
/**
|
|
436
|
+
* A system note: quoted, labelled with where it came from, and deliberately
|
|
437
|
+
* plain — no buttons, no turn footer, and the 👀 receipts stay up, because
|
|
438
|
+
* the turn this input triggers has not ended yet.
|
|
439
|
+
*/
|
|
440
|
+
async notify(conversation, note) {
|
|
441
|
+
const { chatId, topicId } = parseConversation(conversation);
|
|
442
|
+
const label = originLabel(note.origin);
|
|
443
|
+
for (const part of chunk(`<i>${label}</i>\n<blockquote>${toTelegramHtml(note.text)}</blockquote>`)) {
|
|
444
|
+
await this.api.sendMessage({
|
|
445
|
+
chat_id: chatId,
|
|
446
|
+
message_thread_id: topicId,
|
|
447
|
+
text: part,
|
|
448
|
+
parse_mode: "HTML",
|
|
449
|
+
});
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
/** Display name from an update, which always carries enough to build one. */
|
|
454
|
+
const senderName = (user) => [user.first_name, user.last_name].filter(Boolean).join(" ") || user.username || String(user.id);
|
|
455
|
+
/**
|
|
456
|
+
* Deep link to a forum topic. A public supergroup links by username; a private
|
|
457
|
+
* one uses the `/c/<internal id>` form, which is the chat id with its `-100`
|
|
458
|
+
* supergroup prefix removed. Both only resolve for members — exactly the
|
|
459
|
+
* audience standing in General.
|
|
460
|
+
*/
|
|
461
|
+
function topicLink(chat, topicId) {
|
|
462
|
+
if (chat.username)
|
|
463
|
+
return `https://t.me/${chat.username}/${topicId}`;
|
|
464
|
+
const internal = String(chat.id).replace(/^-100(?=\d)/, "").replace(/^-/, "");
|
|
465
|
+
return `https://t.me/c/${internal}/${topicId}`;
|
|
466
|
+
}
|
|
467
|
+
/**
|
|
468
|
+
* The web shows a turn's cost on hover; IM has none, so it becomes a footer.
|
|
469
|
+
* One newline, not a blank line: Telegram has no small or muted text, so the
|
|
470
|
+
* only way to make it read as a footnote instead of its own paragraph is to
|
|
471
|
+
* keep it tucked against the reply.
|
|
472
|
+
*/
|
|
473
|
+
const turnFooter = (meta) => meta ? `\n<i>${formatTurnMeta(meta)}</i>` : "";
|