@femtomc/mu-server 26.2.95 → 26.2.97
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/README.md +11 -0
- package/dist/config.d.ts +3 -0
- package/dist/config.js +11 -0
- package/dist/control_plane.d.ts +21 -0
- package/dist/control_plane.js +373 -75
- package/dist/control_plane_bootstrap_helpers.js +1 -0
- package/package.json +4 -4
package/README.md
CHANGED
|
@@ -91,6 +91,9 @@ Use `mu store paths --pretty` to resolve `<store>` for the active repo/workspace
|
|
|
91
91
|
"memory_index": {
|
|
92
92
|
"enabled": true,
|
|
93
93
|
"every_ms": 300000
|
|
94
|
+
},
|
|
95
|
+
"operator": {
|
|
96
|
+
"timeout_ms": 600000
|
|
94
97
|
}
|
|
95
98
|
}
|
|
96
99
|
}
|
|
@@ -153,6 +156,7 @@ mu control status --pretty
|
|
|
153
156
|
- Discord: `control_plane.adapters.discord.signing_secret`
|
|
154
157
|
- Telegram: `control_plane.adapters.telegram.webhook_secret`, `bot_token`, `bot_username`
|
|
155
158
|
- Neovim: `control_plane.adapters.neovim.shared_secret`
|
|
159
|
+
- Optional operator tuning: `control_plane.operator.timeout_ms` (max wall-time per messaging turn, default `600000`).
|
|
156
160
|
|
|
157
161
|
3) Reload live control-plane runtime:
|
|
158
162
|
|
|
@@ -189,6 +193,13 @@ When validating attachment support end-to-end, use this sequence:
|
|
|
189
193
|
Operational fallback expectations:
|
|
190
194
|
|
|
191
195
|
- If media upload fails, Telegram delivery falls back to text `sendMessage`.
|
|
196
|
+
- Telegram text delivery chunks long messages into deterministic in-order `sendMessage` calls to stay below API size limits.
|
|
197
|
+
- When outbound metadata includes `telegram_reply_to_message_id`, Telegram delivery anchors replies to the originating chat message.
|
|
198
|
+
- Invalid/non-integer `telegram_reply_to_message_id` metadata is ignored so delivery degrades gracefully to non-anchored sends.
|
|
199
|
+
- Awaiting-confirmation envelopes include Telegram inline `Confirm`/`Cancel` callback buttons when interaction metadata provides confirmation actions.
|
|
200
|
+
- Telegram callback payloads are contract-limited to `confirm:<command_id>` and `cancel:<command_id>`; unsupported payloads are explicitly rejected.
|
|
201
|
+
- Callback buttons keep parity with command fallback: `/mu confirm <id>` and `/mu cancel <id>` remain valid.
|
|
202
|
+
- Group/supergroup Telegram freeform text is deterministic no-op with guidance; explicit `/mu ...` commands stay actionable.
|
|
192
203
|
- If Slack/Telegram bot token is missing, channel capability reason codes should report `*_bot_token_missing` and outbound delivery retries rather than hard-crashing runtime.
|
|
193
204
|
|
|
194
205
|
## Running the Server
|
package/dist/config.d.ts
CHANGED
|
@@ -23,6 +23,7 @@ export type MuConfig = {
|
|
|
23
23
|
provider: string | null;
|
|
24
24
|
model: string | null;
|
|
25
25
|
thinking: string | null;
|
|
26
|
+
timeout_ms: number;
|
|
26
27
|
};
|
|
27
28
|
memory_index: {
|
|
28
29
|
enabled: boolean;
|
|
@@ -54,6 +55,7 @@ export type MuConfigPatch = {
|
|
|
54
55
|
provider?: string | null;
|
|
55
56
|
model?: string | null;
|
|
56
57
|
thinking?: string | null;
|
|
58
|
+
timeout_ms?: number;
|
|
57
59
|
};
|
|
58
60
|
memory_index?: {
|
|
59
61
|
enabled?: boolean;
|
|
@@ -85,6 +87,7 @@ export type MuConfigPresence = {
|
|
|
85
87
|
provider: boolean;
|
|
86
88
|
model: boolean;
|
|
87
89
|
thinking: boolean;
|
|
90
|
+
timeout_ms: number;
|
|
88
91
|
};
|
|
89
92
|
memory_index: {
|
|
90
93
|
enabled: boolean;
|
package/dist/config.js
CHANGED
|
@@ -26,6 +26,7 @@ export const DEFAULT_MU_CONFIG = {
|
|
|
26
26
|
provider: null,
|
|
27
27
|
model: null,
|
|
28
28
|
thinking: null,
|
|
29
|
+
timeout_ms: 600_000,
|
|
29
30
|
},
|
|
30
31
|
memory_index: {
|
|
31
32
|
enabled: true,
|
|
@@ -129,6 +130,9 @@ export function normalizeMuConfig(input) {
|
|
|
129
130
|
if ("thinking" in operator) {
|
|
130
131
|
next.control_plane.operator.thinking = normalizeNullableString(operator.thinking);
|
|
131
132
|
}
|
|
133
|
+
if ("timeout_ms" in operator) {
|
|
134
|
+
next.control_plane.operator.timeout_ms = normalizeInteger(operator.timeout_ms, next.control_plane.operator.timeout_ms, { min: 1_000, max: 7_200_000 });
|
|
135
|
+
}
|
|
132
136
|
}
|
|
133
137
|
const memoryIndex = asRecord(controlPlane.memory_index);
|
|
134
138
|
if (memoryIndex) {
|
|
@@ -214,6 +218,9 @@ function normalizeMuConfigPatch(input) {
|
|
|
214
218
|
if ("thinking" in operator) {
|
|
215
219
|
patch.control_plane.operator.thinking = normalizeNullableString(operator.thinking);
|
|
216
220
|
}
|
|
221
|
+
if ("timeout_ms" in operator) {
|
|
222
|
+
patch.control_plane.operator.timeout_ms = normalizeInteger(operator.timeout_ms, DEFAULT_MU_CONFIG.control_plane.operator.timeout_ms, { min: 1_000, max: 7_200_000 });
|
|
223
|
+
}
|
|
217
224
|
if (Object.keys(patch.control_plane.operator).length === 0) {
|
|
218
225
|
delete patch.control_plane.operator;
|
|
219
226
|
}
|
|
@@ -288,6 +295,9 @@ export function applyMuConfigPatch(base, patchInput) {
|
|
|
288
295
|
if ("thinking" in operator) {
|
|
289
296
|
next.control_plane.operator.thinking = operator.thinking ?? null;
|
|
290
297
|
}
|
|
298
|
+
if ("timeout_ms" in operator && typeof operator.timeout_ms === "number" && Number.isFinite(operator.timeout_ms)) {
|
|
299
|
+
next.control_plane.operator.timeout_ms = normalizeInteger(operator.timeout_ms, next.control_plane.operator.timeout_ms, { min: 1_000, max: 7_200_000 });
|
|
300
|
+
}
|
|
291
301
|
}
|
|
292
302
|
const memoryIndex = patch.control_plane.memory_index;
|
|
293
303
|
if (memoryIndex) {
|
|
@@ -373,6 +383,7 @@ export function muConfigPresence(config) {
|
|
|
373
383
|
provider: isPresent(config.control_plane.operator.provider),
|
|
374
384
|
model: isPresent(config.control_plane.operator.model),
|
|
375
385
|
thinking: isPresent(config.control_plane.operator.thinking),
|
|
386
|
+
timeout_ms: config.control_plane.operator.timeout_ms,
|
|
376
387
|
},
|
|
377
388
|
memory_index: {
|
|
378
389
|
enabled: config.control_plane.memory_index.enabled,
|
package/dist/control_plane.d.ts
CHANGED
|
@@ -4,21 +4,35 @@ import { type ControlPlaneConfig, type ControlPlaneGenerationContext, type Contr
|
|
|
4
4
|
import { detectAdapters } from "./control_plane_adapter_registry.js";
|
|
5
5
|
export type { ActiveAdapter, ControlPlaneConfig, ControlPlaneGenerationContext, ControlPlaneHandle, ControlPlaneSessionLifecycle, ControlPlaneSessionMutationAction, ControlPlaneSessionMutationResult, NotifyOperatorsOpts, NotifyOperatorsResult, TelegramGenerationReloadResult, TelegramGenerationRollbackTrigger, TelegramGenerationSwapHooks, WakeDeliveryEvent, WakeDeliveryObserver, WakeNotifyContext, WakeNotifyDecision, } from "./control_plane_contract.js";
|
|
6
6
|
export { detectAdapters };
|
|
7
|
+
type TelegramInlineKeyboardButton = {
|
|
8
|
+
text: string;
|
|
9
|
+
callback_data: string;
|
|
10
|
+
};
|
|
11
|
+
type TelegramInlineKeyboardMarkup = {
|
|
12
|
+
inline_keyboard: TelegramInlineKeyboardButton[][];
|
|
13
|
+
};
|
|
7
14
|
export type TelegramSendMessagePayload = {
|
|
8
15
|
chat_id: string;
|
|
9
16
|
text: string;
|
|
10
17
|
parse_mode?: "Markdown";
|
|
11
18
|
disable_web_page_preview?: boolean;
|
|
19
|
+
reply_markup?: TelegramInlineKeyboardMarkup;
|
|
20
|
+
reply_to_message_id?: number;
|
|
21
|
+
allow_sending_without_reply?: boolean;
|
|
12
22
|
};
|
|
13
23
|
export type TelegramSendPhotoPayload = {
|
|
14
24
|
chat_id: string;
|
|
15
25
|
photo: string;
|
|
16
26
|
caption?: string;
|
|
27
|
+
reply_to_message_id?: number;
|
|
28
|
+
allow_sending_without_reply?: boolean;
|
|
17
29
|
};
|
|
18
30
|
export type TelegramSendDocumentPayload = {
|
|
19
31
|
chat_id: string;
|
|
20
32
|
document: string;
|
|
21
33
|
caption?: string;
|
|
34
|
+
reply_to_message_id?: number;
|
|
35
|
+
allow_sending_without_reply?: boolean;
|
|
22
36
|
};
|
|
23
37
|
/**
|
|
24
38
|
* Telegram supports a markdown dialect that uses single markers for emphasis.
|
|
@@ -26,12 +40,19 @@ export type TelegramSendDocumentPayload = {
|
|
|
26
40
|
* while preserving fenced code blocks verbatim.
|
|
27
41
|
*/
|
|
28
42
|
export declare function renderTelegramMarkdown(text: string): string;
|
|
43
|
+
/**
|
|
44
|
+
* Slack mrkdwn does not support Markdown headings.
|
|
45
|
+
* Normalize common heading markers while preserving fenced blocks.
|
|
46
|
+
*/
|
|
47
|
+
export declare function renderSlackMarkdown(text: string): string;
|
|
29
48
|
export declare function containsTelegramMathNotation(text: string): boolean;
|
|
30
49
|
export declare function buildTelegramSendMessagePayload(opts: {
|
|
31
50
|
chatId: string;
|
|
32
51
|
text: string;
|
|
33
52
|
richFormatting: boolean;
|
|
34
53
|
}): TelegramSendMessagePayload;
|
|
54
|
+
export declare function splitTelegramMessageText(text: string, maxLen?: number): string[];
|
|
55
|
+
export declare function splitSlackMessageText(text: string, maxLen?: number): string[];
|
|
35
56
|
export declare function deliverTelegramOutboxRecord(opts: {
|
|
36
57
|
botToken: string;
|
|
37
58
|
record: OutboxRecord;
|
package/dist/control_plane.js
CHANGED
|
@@ -53,6 +53,34 @@ export function renderTelegramMarkdown(text) {
|
|
|
53
53
|
}
|
|
54
54
|
return out.join("\n");
|
|
55
55
|
}
|
|
56
|
+
/**
|
|
57
|
+
* Slack mrkdwn does not support Markdown headings.
|
|
58
|
+
* Normalize common heading markers while preserving fenced blocks.
|
|
59
|
+
*/
|
|
60
|
+
export function renderSlackMarkdown(text) {
|
|
61
|
+
const normalized = text.replaceAll("\r\n", "\n");
|
|
62
|
+
const lines = normalized.split("\n");
|
|
63
|
+
const out = [];
|
|
64
|
+
let inFence = false;
|
|
65
|
+
for (const line of lines) {
|
|
66
|
+
const trimmed = line.trimStart();
|
|
67
|
+
if (trimmed.startsWith("```")) {
|
|
68
|
+
inFence = !inFence;
|
|
69
|
+
out.push(line);
|
|
70
|
+
continue;
|
|
71
|
+
}
|
|
72
|
+
if (inFence) {
|
|
73
|
+
out.push(line);
|
|
74
|
+
continue;
|
|
75
|
+
}
|
|
76
|
+
let next = line;
|
|
77
|
+
next = next.replace(/^#{1,6}\s+(.+)$/, "*$1*");
|
|
78
|
+
next = next.replace(/\*\*(.+?)\*\*/g, "*$1*");
|
|
79
|
+
next = next.replace(/__(.+?)__/g, "_$1_");
|
|
80
|
+
out.push(next);
|
|
81
|
+
}
|
|
82
|
+
return out.join("\n");
|
|
83
|
+
}
|
|
56
84
|
const TELEGRAM_MATH_PATTERNS = [
|
|
57
85
|
/\$\$[\s\S]+?\$\$/m,
|
|
58
86
|
/(^|[^\\])\$[^$\n]+\$/m,
|
|
@@ -79,6 +107,155 @@ export function buildTelegramSendMessagePayload(opts) {
|
|
|
79
107
|
disable_web_page_preview: true,
|
|
80
108
|
};
|
|
81
109
|
}
|
|
110
|
+
const TELEGRAM_MESSAGE_MAX_LEN = 4_096;
|
|
111
|
+
function maybeParseTelegramMessageId(value) {
|
|
112
|
+
if (typeof value === "number" && Number.isFinite(value)) {
|
|
113
|
+
return Math.trunc(value);
|
|
114
|
+
}
|
|
115
|
+
if (typeof value === "string" && /^-?\d+$/.test(value.trim())) {
|
|
116
|
+
const parsed = Number.parseInt(value.trim(), 10);
|
|
117
|
+
if (Number.isFinite(parsed)) {
|
|
118
|
+
return parsed;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
return null;
|
|
122
|
+
}
|
|
123
|
+
export function splitTelegramMessageText(text, maxLen = TELEGRAM_MESSAGE_MAX_LEN) {
|
|
124
|
+
if (text.length <= maxLen) {
|
|
125
|
+
return [text];
|
|
126
|
+
}
|
|
127
|
+
const chunks = [];
|
|
128
|
+
let cursor = 0;
|
|
129
|
+
while (cursor < text.length) {
|
|
130
|
+
const end = Math.min(text.length, cursor + maxLen);
|
|
131
|
+
if (end === text.length) {
|
|
132
|
+
chunks.push(text.slice(cursor));
|
|
133
|
+
break;
|
|
134
|
+
}
|
|
135
|
+
const window = text.slice(cursor, end);
|
|
136
|
+
const splitAtNewline = window.lastIndexOf("\n");
|
|
137
|
+
const splitPoint = splitAtNewline >= Math.floor(maxLen * 0.5) ? cursor + splitAtNewline + 1 : end;
|
|
138
|
+
chunks.push(text.slice(cursor, splitPoint));
|
|
139
|
+
cursor = splitPoint;
|
|
140
|
+
}
|
|
141
|
+
return chunks;
|
|
142
|
+
}
|
|
143
|
+
const SLACK_MESSAGE_MAX_LEN = 3_500;
|
|
144
|
+
export function splitSlackMessageText(text, maxLen = SLACK_MESSAGE_MAX_LEN) {
|
|
145
|
+
if (text.length <= maxLen) {
|
|
146
|
+
return [text];
|
|
147
|
+
}
|
|
148
|
+
const chunks = [];
|
|
149
|
+
let cursor = 0;
|
|
150
|
+
while (cursor < text.length) {
|
|
151
|
+
const end = Math.min(text.length, cursor + maxLen);
|
|
152
|
+
if (end === text.length) {
|
|
153
|
+
chunks.push(text.slice(cursor));
|
|
154
|
+
break;
|
|
155
|
+
}
|
|
156
|
+
const window = text.slice(cursor, end);
|
|
157
|
+
const splitAtNewline = window.lastIndexOf("\n");
|
|
158
|
+
const splitPoint = splitAtNewline >= Math.floor(maxLen * 0.5) ? cursor + splitAtNewline + 1 : end;
|
|
159
|
+
chunks.push(text.slice(cursor, splitPoint));
|
|
160
|
+
cursor = splitPoint;
|
|
161
|
+
}
|
|
162
|
+
return chunks;
|
|
163
|
+
}
|
|
164
|
+
function slackBlocksForOutboxRecord(record, body) {
|
|
165
|
+
const interactionMessage = record.envelope.metadata?.interaction_message;
|
|
166
|
+
if (!interactionMessage || typeof interactionMessage !== "object") {
|
|
167
|
+
return undefined;
|
|
168
|
+
}
|
|
169
|
+
const rawActions = interactionMessage.actions;
|
|
170
|
+
if (!Array.isArray(rawActions) || rawActions.length === 0) {
|
|
171
|
+
return undefined;
|
|
172
|
+
}
|
|
173
|
+
const buttons = [];
|
|
174
|
+
for (const action of rawActions) {
|
|
175
|
+
if (!action || typeof action !== "object") {
|
|
176
|
+
continue;
|
|
177
|
+
}
|
|
178
|
+
const candidate = action;
|
|
179
|
+
if (typeof candidate.label !== "string" || typeof candidate.command !== "string") {
|
|
180
|
+
continue;
|
|
181
|
+
}
|
|
182
|
+
const normalized = candidate.command.trim().replace(/^\/mu\s+/i, "");
|
|
183
|
+
const confirm = /^confirm\s+([^\s]+)$/i.exec(normalized);
|
|
184
|
+
if (confirm?.[1]) {
|
|
185
|
+
buttons.push({
|
|
186
|
+
type: "button",
|
|
187
|
+
text: { type: "plain_text", text: candidate.label },
|
|
188
|
+
value: `confirm:${confirm[1]}`,
|
|
189
|
+
action_id: `confirm_${confirm[1]}`,
|
|
190
|
+
});
|
|
191
|
+
continue;
|
|
192
|
+
}
|
|
193
|
+
const cancel = /^cancel\s+([^\s]+)$/i.exec(normalized);
|
|
194
|
+
if (cancel?.[1]) {
|
|
195
|
+
buttons.push({
|
|
196
|
+
type: "button",
|
|
197
|
+
text: { type: "plain_text", text: candidate.label },
|
|
198
|
+
value: `cancel:${cancel[1]}`,
|
|
199
|
+
action_id: `cancel_${cancel[1]}`,
|
|
200
|
+
});
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
if (buttons.length === 0) {
|
|
204
|
+
return undefined;
|
|
205
|
+
}
|
|
206
|
+
return [
|
|
207
|
+
{ type: "section", text: { type: "mrkdwn", text: body } },
|
|
208
|
+
{ type: "actions", elements: buttons },
|
|
209
|
+
];
|
|
210
|
+
}
|
|
211
|
+
function slackThreadTsFromMetadata(metadata) {
|
|
212
|
+
const candidates = [metadata?.slack_thread_ts, metadata?.slack_message_ts, metadata?.thread_ts];
|
|
213
|
+
for (const value of candidates) {
|
|
214
|
+
if (typeof value === "string" && value.trim().length > 0) {
|
|
215
|
+
return value.trim();
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
return undefined;
|
|
219
|
+
}
|
|
220
|
+
function slackStatusMessageTsFromMetadata(metadata) {
|
|
221
|
+
const value = metadata?.slack_status_message_ts;
|
|
222
|
+
if (typeof value !== "string") {
|
|
223
|
+
return undefined;
|
|
224
|
+
}
|
|
225
|
+
const trimmed = value.trim();
|
|
226
|
+
return trimmed.length > 0 ? trimmed : undefined;
|
|
227
|
+
}
|
|
228
|
+
function telegramReplyMarkupForOutboxRecord(record) {
|
|
229
|
+
const interactionMessage = record.envelope.metadata?.interaction_message;
|
|
230
|
+
if (!interactionMessage || typeof interactionMessage !== "object") {
|
|
231
|
+
return undefined;
|
|
232
|
+
}
|
|
233
|
+
const rawActions = interactionMessage.actions;
|
|
234
|
+
if (!Array.isArray(rawActions) || rawActions.length === 0) {
|
|
235
|
+
return undefined;
|
|
236
|
+
}
|
|
237
|
+
const row = [];
|
|
238
|
+
for (const action of rawActions) {
|
|
239
|
+
if (!action || typeof action !== "object") {
|
|
240
|
+
continue;
|
|
241
|
+
}
|
|
242
|
+
const candidate = action;
|
|
243
|
+
if (typeof candidate.label !== "string" || typeof candidate.command !== "string") {
|
|
244
|
+
continue;
|
|
245
|
+
}
|
|
246
|
+
const normalized = candidate.command.trim().replace(/^\/mu\s+/i, "");
|
|
247
|
+
const confirm = /^confirm\s+([^\s]+)$/i.exec(normalized);
|
|
248
|
+
if (confirm?.[1]) {
|
|
249
|
+
row.push({ text: candidate.label, callback_data: `confirm:${confirm[1]}` });
|
|
250
|
+
continue;
|
|
251
|
+
}
|
|
252
|
+
const cancel = /^cancel\s+([^\s]+)$/i.exec(normalized);
|
|
253
|
+
if (cancel?.[1]) {
|
|
254
|
+
row.push({ text: candidate.label, callback_data: `cancel:${cancel[1]}` });
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
return row.length > 0 ? { inline_keyboard: [row] } : undefined;
|
|
258
|
+
}
|
|
82
259
|
async function postTelegramMessage(botToken, payload) {
|
|
83
260
|
return await fetch(`https://api.telegram.org/bot${botToken}/sendMessage`, {
|
|
84
261
|
method: "POST",
|
|
@@ -133,37 +310,76 @@ function parseRetryDelayMs(res) {
|
|
|
133
310
|
}
|
|
134
311
|
return parsed * 1000;
|
|
135
312
|
}
|
|
313
|
+
async function sendTelegramTextChunks(opts) {
|
|
314
|
+
const chunks = splitTelegramMessageText(opts.basePayload.text);
|
|
315
|
+
for (const [index, chunk] of chunks.entries()) {
|
|
316
|
+
const payload = {
|
|
317
|
+
...opts.basePayload,
|
|
318
|
+
text: chunk,
|
|
319
|
+
...(index === 0 ? {} : { reply_markup: undefined, reply_to_message_id: undefined, allow_sending_without_reply: undefined }),
|
|
320
|
+
};
|
|
321
|
+
let res = await postTelegramMessage(opts.botToken, payload);
|
|
322
|
+
if (!res.ok && res.status === 400 && payload.parse_mode && opts.fallbackToPlainOnMarkdownError) {
|
|
323
|
+
const plainPayload = buildTelegramSendMessagePayload({
|
|
324
|
+
chatId: payload.chat_id,
|
|
325
|
+
text: payload.text,
|
|
326
|
+
richFormatting: false,
|
|
327
|
+
});
|
|
328
|
+
res = await postTelegramMessage(opts.botToken, {
|
|
329
|
+
...plainPayload,
|
|
330
|
+
...(payload.reply_markup ? { reply_markup: payload.reply_markup } : {}),
|
|
331
|
+
...(payload.reply_to_message_id != null
|
|
332
|
+
? {
|
|
333
|
+
reply_to_message_id: payload.reply_to_message_id,
|
|
334
|
+
allow_sending_without_reply: payload.allow_sending_without_reply,
|
|
335
|
+
}
|
|
336
|
+
: {}),
|
|
337
|
+
});
|
|
338
|
+
}
|
|
339
|
+
if (!res.ok) {
|
|
340
|
+
return { ok: false, response: res, body: await res.text().catch(() => "") };
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
return { ok: true };
|
|
344
|
+
}
|
|
136
345
|
export async function deliverTelegramOutboxRecord(opts) {
|
|
137
346
|
const { botToken, record } = opts;
|
|
138
|
-
const
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
347
|
+
const replyMarkup = telegramReplyMarkupForOutboxRecord(record);
|
|
348
|
+
const replyToMessageId = maybeParseTelegramMessageId(record.envelope.metadata?.telegram_reply_to_message_id);
|
|
349
|
+
const fallbackMessagePayload = {
|
|
350
|
+
...buildTelegramSendMessagePayload({
|
|
351
|
+
chatId: record.envelope.channel_conversation_id,
|
|
352
|
+
text: record.envelope.body,
|
|
353
|
+
richFormatting: true,
|
|
354
|
+
}),
|
|
355
|
+
...(replyMarkup ? { reply_markup: replyMarkup } : {}),
|
|
356
|
+
...(replyToMessageId != null
|
|
357
|
+
? {
|
|
358
|
+
reply_to_message_id: replyToMessageId,
|
|
359
|
+
allow_sending_without_reply: true,
|
|
360
|
+
}
|
|
361
|
+
: {}),
|
|
362
|
+
};
|
|
143
363
|
const firstAttachment = record.envelope.attachments?.[0] ?? null;
|
|
144
364
|
if (!firstAttachment) {
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
}));
|
|
152
|
-
}
|
|
153
|
-
if (res.ok) {
|
|
365
|
+
const sent = await sendTelegramTextChunks({
|
|
366
|
+
botToken,
|
|
367
|
+
basePayload: fallbackMessagePayload,
|
|
368
|
+
fallbackToPlainOnMarkdownError: true,
|
|
369
|
+
});
|
|
370
|
+
if (sent.ok) {
|
|
154
371
|
return { kind: "delivered" };
|
|
155
372
|
}
|
|
156
|
-
|
|
157
|
-
if (res.status === 429 || res.status >= 500) {
|
|
373
|
+
if (sent.response.status === 429 || sent.response.status >= 500) {
|
|
158
374
|
return {
|
|
159
375
|
kind: "retry",
|
|
160
|
-
error: `telegram sendMessage ${
|
|
161
|
-
retryDelayMs: parseRetryDelayMs(
|
|
376
|
+
error: `telegram sendMessage ${sent.response.status}: ${sent.body}`,
|
|
377
|
+
retryDelayMs: parseRetryDelayMs(sent.response),
|
|
162
378
|
};
|
|
163
379
|
}
|
|
164
380
|
return {
|
|
165
381
|
kind: "retry",
|
|
166
|
-
error: `telegram sendMessage ${
|
|
382
|
+
error: `telegram sendMessage ${sent.response.status}: ${sent.body}`,
|
|
167
383
|
};
|
|
168
384
|
}
|
|
169
385
|
const mediaMethod = chooseTelegramMediaMethod(firstAttachment);
|
|
@@ -180,11 +396,23 @@ export async function deliverTelegramOutboxRecord(opts) {
|
|
|
180
396
|
chat_id: record.envelope.channel_conversation_id,
|
|
181
397
|
photo: firstAttachment.reference.file_id,
|
|
182
398
|
caption: mediaCaption,
|
|
399
|
+
...(replyToMessageId != null
|
|
400
|
+
? {
|
|
401
|
+
reply_to_message_id: replyToMessageId,
|
|
402
|
+
allow_sending_without_reply: true,
|
|
403
|
+
}
|
|
404
|
+
: {}),
|
|
183
405
|
}
|
|
184
406
|
: {
|
|
185
407
|
chat_id: record.envelope.channel_conversation_id,
|
|
186
408
|
document: firstAttachment.reference.file_id,
|
|
187
409
|
caption: mediaCaption,
|
|
410
|
+
...(replyToMessageId != null
|
|
411
|
+
? {
|
|
412
|
+
reply_to_message_id: replyToMessageId,
|
|
413
|
+
allow_sending_without_reply: true,
|
|
414
|
+
}
|
|
415
|
+
: {}),
|
|
188
416
|
});
|
|
189
417
|
}
|
|
190
418
|
else {
|
|
@@ -206,6 +434,10 @@ export async function deliverTelegramOutboxRecord(opts) {
|
|
|
206
434
|
if (mediaCaption.length > 0) {
|
|
207
435
|
form.append("caption", mediaCaption);
|
|
208
436
|
}
|
|
437
|
+
if (replyToMessageId != null) {
|
|
438
|
+
form.append("reply_to_message_id", String(replyToMessageId));
|
|
439
|
+
form.append("allow_sending_without_reply", "true");
|
|
440
|
+
}
|
|
209
441
|
form.append(mediaField, new Blob([body], { type: contentType }), filename);
|
|
210
442
|
mediaResponse = await postTelegramApiMultipart(botToken, mediaMethod, form);
|
|
211
443
|
}
|
|
@@ -220,26 +452,38 @@ export async function deliverTelegramOutboxRecord(opts) {
|
|
|
220
452
|
retryDelayMs: parseRetryDelayMs(mediaResponse),
|
|
221
453
|
};
|
|
222
454
|
}
|
|
223
|
-
const fallbackPlainPayload =
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
455
|
+
const fallbackPlainPayload = {
|
|
456
|
+
...buildTelegramSendMessagePayload({
|
|
457
|
+
chatId: record.envelope.channel_conversation_id,
|
|
458
|
+
text: record.envelope.body,
|
|
459
|
+
richFormatting: false,
|
|
460
|
+
}),
|
|
461
|
+
...(replyMarkup ? { reply_markup: replyMarkup } : {}),
|
|
462
|
+
...(replyToMessageId != null
|
|
463
|
+
? {
|
|
464
|
+
reply_to_message_id: replyToMessageId,
|
|
465
|
+
allow_sending_without_reply: true,
|
|
466
|
+
}
|
|
467
|
+
: {}),
|
|
468
|
+
};
|
|
469
|
+
const fallbackSent = await sendTelegramTextChunks({
|
|
470
|
+
botToken,
|
|
471
|
+
basePayload: fallbackPlainPayload,
|
|
472
|
+
fallbackToPlainOnMarkdownError: false,
|
|
227
473
|
});
|
|
228
|
-
|
|
229
|
-
if (fallbackRes.ok) {
|
|
474
|
+
if (fallbackSent.ok) {
|
|
230
475
|
return { kind: "delivered" };
|
|
231
476
|
}
|
|
232
|
-
|
|
233
|
-
if (fallbackRes.status === 429 || fallbackRes.status >= 500) {
|
|
477
|
+
if (fallbackSent.response.status === 429 || fallbackSent.response.status >= 500) {
|
|
234
478
|
return {
|
|
235
479
|
kind: "retry",
|
|
236
|
-
error: `telegram media fallback sendMessage ${
|
|
237
|
-
retryDelayMs: parseRetryDelayMs(
|
|
480
|
+
error: `telegram media fallback sendMessage ${fallbackSent.response.status}: ${fallbackSent.body}`,
|
|
481
|
+
retryDelayMs: parseRetryDelayMs(fallbackSent.response),
|
|
238
482
|
};
|
|
239
483
|
}
|
|
240
484
|
return {
|
|
241
485
|
kind: "retry",
|
|
242
|
-
error: `telegram media fallback sendMessage ${
|
|
486
|
+
error: `telegram media fallback sendMessage ${fallbackSent.response.status}: ${fallbackSent.body} (media_error=${mediaMethod} ${mediaResponse.status}: ${mediaBody})`,
|
|
243
487
|
};
|
|
244
488
|
}
|
|
245
489
|
async function postSlackJson(opts) {
|
|
@@ -257,30 +501,75 @@ async function postSlackJson(opts) {
|
|
|
257
501
|
export async function deliverSlackOutboxRecord(opts) {
|
|
258
502
|
const { botToken, record } = opts;
|
|
259
503
|
const attachments = record.envelope.attachments ?? [];
|
|
504
|
+
const renderedBody = renderSlackMarkdown(record.envelope.body);
|
|
505
|
+
const textChunks = splitSlackMessageText(renderedBody);
|
|
506
|
+
const blocks = slackBlocksForOutboxRecord(record, renderedBody);
|
|
507
|
+
const threadTs = slackThreadTsFromMetadata(record.envelope.metadata);
|
|
508
|
+
const statusMessageTs = slackStatusMessageTsFromMetadata(record.envelope.metadata);
|
|
260
509
|
if (attachments.length === 0) {
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
510
|
+
let chunkStartIndex = 0;
|
|
511
|
+
if (statusMessageTs && textChunks.length > 0) {
|
|
512
|
+
const updated = await postSlackJson({
|
|
513
|
+
botToken,
|
|
514
|
+
method: "chat.update",
|
|
515
|
+
payload: {
|
|
516
|
+
channel: record.envelope.channel_conversation_id,
|
|
517
|
+
ts: statusMessageTs,
|
|
518
|
+
text: textChunks[0],
|
|
519
|
+
unfurl_links: false,
|
|
520
|
+
unfurl_media: false,
|
|
521
|
+
...(blocks ? { blocks } : {}),
|
|
522
|
+
},
|
|
523
|
+
});
|
|
524
|
+
if (updated.response.ok && updated.payload?.ok) {
|
|
525
|
+
chunkStartIndex = 1;
|
|
526
|
+
}
|
|
527
|
+
else {
|
|
528
|
+
const status = updated.response.status;
|
|
529
|
+
const err = updated.payload?.error ?? "unknown_error";
|
|
530
|
+
if (status === 429 || status >= 500) {
|
|
531
|
+
return {
|
|
532
|
+
kind: "retry",
|
|
533
|
+
error: `slack chat.update ${status}: ${err}`,
|
|
534
|
+
retryDelayMs: parseRetryDelayMs(updated.response),
|
|
535
|
+
};
|
|
536
|
+
}
|
|
537
|
+
if (err !== "message_not_found" && err !== "cant_update_message") {
|
|
538
|
+
return { kind: "retry", error: `slack chat.update ${status}: ${err}` };
|
|
539
|
+
}
|
|
540
|
+
}
|
|
273
541
|
}
|
|
274
|
-
const
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
542
|
+
for (const [index, chunk] of textChunks.entries()) {
|
|
543
|
+
if (index < chunkStartIndex) {
|
|
544
|
+
continue;
|
|
545
|
+
}
|
|
546
|
+
const delivered = await postSlackJson({
|
|
547
|
+
botToken,
|
|
548
|
+
method: "chat.postMessage",
|
|
549
|
+
payload: {
|
|
550
|
+
channel: record.envelope.channel_conversation_id,
|
|
551
|
+
text: chunk,
|
|
552
|
+
unfurl_links: false,
|
|
553
|
+
unfurl_media: false,
|
|
554
|
+
...(index === 0 && blocks ? { blocks } : {}),
|
|
555
|
+
...(threadTs ? { thread_ts: threadTs } : {}),
|
|
556
|
+
},
|
|
557
|
+
});
|
|
558
|
+
if (delivered.response.ok && delivered.payload?.ok) {
|
|
559
|
+
continue;
|
|
560
|
+
}
|
|
561
|
+
const status = delivered.response.status;
|
|
562
|
+
const err = delivered.payload?.error ?? "unknown_error";
|
|
563
|
+
if (status === 429 || status >= 500) {
|
|
564
|
+
return {
|
|
565
|
+
kind: "retry",
|
|
566
|
+
error: `slack chat.postMessage ${status}: ${err}`,
|
|
567
|
+
retryDelayMs: parseRetryDelayMs(delivered.response),
|
|
568
|
+
};
|
|
569
|
+
}
|
|
570
|
+
return { kind: "retry", error: `slack chat.postMessage ${status}: ${err}` };
|
|
282
571
|
}
|
|
283
|
-
return { kind: "
|
|
572
|
+
return { kind: "delivered" };
|
|
284
573
|
}
|
|
285
574
|
let firstError = null;
|
|
286
575
|
for (const [index, attachment] of attachments.entries()) {
|
|
@@ -311,7 +600,10 @@ export async function deliverSlackOutboxRecord(opts) {
|
|
|
311
600
|
form.set("filename", filename);
|
|
312
601
|
form.set("title", filename);
|
|
313
602
|
if (index === 0 && record.envelope.body.trim().length > 0) {
|
|
314
|
-
form.set("initial_comment", record.envelope.body);
|
|
603
|
+
form.set("initial_comment", textChunks[0] ?? record.envelope.body);
|
|
604
|
+
}
|
|
605
|
+
if (threadTs) {
|
|
606
|
+
form.set("thread_ts", threadTs);
|
|
315
607
|
}
|
|
316
608
|
form.set("file", new Blob([bytes], { type: contentType }), filename);
|
|
317
609
|
const uploaded = await fetch("https://slack.com/api/files.upload", {
|
|
@@ -337,33 +629,39 @@ export async function deliverSlackOutboxRecord(opts) {
|
|
|
337
629
|
}
|
|
338
630
|
}
|
|
339
631
|
}
|
|
340
|
-
if (firstError) {
|
|
341
|
-
const
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
632
|
+
if (firstError || textChunks.length > 1) {
|
|
633
|
+
for (const [index, chunk] of textChunks.entries()) {
|
|
634
|
+
if (index === 0 && !firstError) {
|
|
635
|
+
continue;
|
|
636
|
+
}
|
|
637
|
+
const fallback = await postSlackJson({
|
|
638
|
+
botToken,
|
|
639
|
+
method: "chat.postMessage",
|
|
640
|
+
payload: {
|
|
641
|
+
channel: record.envelope.channel_conversation_id,
|
|
642
|
+
text: chunk,
|
|
643
|
+
unfurl_links: false,
|
|
644
|
+
unfurl_media: false,
|
|
645
|
+
...(threadTs ? { thread_ts: threadTs } : {}),
|
|
646
|
+
},
|
|
647
|
+
});
|
|
648
|
+
if (fallback.response.ok && fallback.payload?.ok) {
|
|
649
|
+
continue;
|
|
650
|
+
}
|
|
651
|
+
const status = fallback.response.status;
|
|
652
|
+
const err = fallback.payload?.error ?? "unknown_error";
|
|
653
|
+
if (status === 429 || status >= 500) {
|
|
654
|
+
return {
|
|
655
|
+
kind: "retry",
|
|
656
|
+
error: `slack chat.postMessage fallback ${status}: ${err}${firstError ? ` (upload_error=${firstError})` : ""}`,
|
|
657
|
+
retryDelayMs: parseRetryDelayMs(fallback.response),
|
|
658
|
+
};
|
|
659
|
+
}
|
|
357
660
|
return {
|
|
358
661
|
kind: "retry",
|
|
359
|
-
error: `slack chat.postMessage fallback ${status}: ${err} (upload_error=${firstError})`,
|
|
360
|
-
retryDelayMs: parseRetryDelayMs(fallback.response),
|
|
662
|
+
error: `slack chat.postMessage fallback ${status}: ${err}${firstError ? ` (upload_error=${firstError})` : ""}`,
|
|
361
663
|
};
|
|
362
664
|
}
|
|
363
|
-
return {
|
|
364
|
-
kind: "retry",
|
|
365
|
-
error: `slack chat.postMessage fallback ${status}: ${err} (upload_error=${firstError})`,
|
|
366
|
-
};
|
|
367
665
|
}
|
|
368
666
|
return { kind: "delivered" };
|
|
369
667
|
}
|
|
@@ -11,6 +11,7 @@ export function buildMessagingOperatorRuntime(opts) {
|
|
|
11
11
|
provider: opts.config.operator.provider ?? undefined,
|
|
12
12
|
model: opts.config.operator.model ?? undefined,
|
|
13
13
|
thinking: opts.config.operator.thinking ?? undefined,
|
|
14
|
+
timeoutMs: opts.config.operator.timeout_ms,
|
|
14
15
|
extensionPaths: operatorExtensionPaths,
|
|
15
16
|
});
|
|
16
17
|
const conversationSessionStore = new JsonFileConversationSessionStore(join(getControlPlanePaths(opts.repoRoot).controlPlaneDir, "operator_conversations.json"));
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@femtomc/mu-server",
|
|
3
|
-
"version": "26.2.
|
|
3
|
+
"version": "26.2.97",
|
|
4
4
|
"description": "HTTP API server for mu control-plane transport/session plus run/activity scheduling coordination.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"mu",
|
|
@@ -30,8 +30,8 @@
|
|
|
30
30
|
"start": "bun run dist/cli.js"
|
|
31
31
|
},
|
|
32
32
|
"dependencies": {
|
|
33
|
-
"@femtomc/mu-agent": "26.2.
|
|
34
|
-
"@femtomc/mu-control-plane": "26.2.
|
|
35
|
-
"@femtomc/mu-core": "26.2.
|
|
33
|
+
"@femtomc/mu-agent": "26.2.97",
|
|
34
|
+
"@femtomc/mu-control-plane": "26.2.97",
|
|
35
|
+
"@femtomc/mu-core": "26.2.97"
|
|
36
36
|
}
|
|
37
37
|
}
|