@llblab/pi-telegram 0.12.0 → 0.13.0

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/lib/locks.ts CHANGED
@@ -125,7 +125,10 @@ export function writeLocks(path: string, locks: Record<string, unknown>): void {
125
125
  mkdirSync(dirname(path), { recursive: true });
126
126
  const tempPath = `${path}.${process.pid}.${Date.now()}.tmp`;
127
127
  try {
128
- writeFileSync(tempPath, `${JSON.stringify(locks, null, 2)}\n`, "utf8");
128
+ writeFileSync(tempPath, `${JSON.stringify(locks, null, 2)}\n`, {
129
+ encoding: "utf8",
130
+ mode: 0o600,
131
+ });
129
132
  renameSync(tempPath, path);
130
133
  } catch (error) {
131
134
  try {
@@ -0,0 +1,226 @@
1
+ /**
2
+ * Telegram outbound button helpers
3
+ * Zones: telegram outbound, assistant markup, callback routing
4
+ * Owns assistant-authored telegram_button extraction, button action storage, callback handling, and prompt-turn construction
5
+ */
6
+
7
+ import { randomUUID } from "node:crypto";
8
+
9
+ import type { TelegramInlineKeyboardMarkup } from "./keyboard.ts";
10
+ import {
11
+ parseTelegramCommentAttributes,
12
+ parseTopLevelTelegramComment,
13
+ replaceTopLevelHtmlComments,
14
+ } from "./outbound-markup.ts";
15
+ import {
16
+ type PendingTelegramTurn,
17
+ truncateTelegramQueueSummary,
18
+ } from "./queue.ts";
19
+
20
+ const TELEGRAM_BUTTON_CALLBACK_PREFIX = "tgbtn";
21
+ const TELEGRAM_BUTTON_ACTION_TTL_MS = 24 * 60 * 60 * 1000;
22
+
23
+ export interface TelegramOutboundButtonAction {
24
+ text: string;
25
+ prompt: string;
26
+ }
27
+
28
+ export interface TelegramOutboundButtonStoredAction extends TelegramOutboundButtonAction {
29
+ createdAt: number;
30
+ }
31
+
32
+ export type TelegramOutboundButtonMarkup = TelegramInlineKeyboardMarkup;
33
+
34
+ export interface TelegramButtonReplyPlan {
35
+ markdown: string;
36
+ replyMarkup?: TelegramOutboundButtonMarkup;
37
+ }
38
+
39
+ export interface TelegramButtonActionStore {
40
+ register: (action: TelegramOutboundButtonAction) => string;
41
+ resolve: (
42
+ callbackData: string | undefined,
43
+ ) => TelegramOutboundButtonAction | undefined;
44
+ }
45
+
46
+ export interface TelegramButtonCallbackQuery {
47
+ id: string;
48
+ data?: string;
49
+ message?: {
50
+ message_id?: number;
51
+ chat?: { id?: number };
52
+ };
53
+ }
54
+
55
+ export interface TelegramButtonCallbackHandlerDeps<TContext = unknown> {
56
+ resolveAction: (
57
+ callbackData: string | undefined,
58
+ ) => TelegramOutboundButtonAction | undefined;
59
+ answerCallbackQuery: (
60
+ callbackQueryId: string,
61
+ text?: string,
62
+ ) => Promise<void>;
63
+ enqueueButtonPrompt: (
64
+ query: TelegramButtonCallbackQuery,
65
+ action: TelegramOutboundButtonAction,
66
+ ctx: TContext,
67
+ ) => void;
68
+ }
69
+
70
+ function nowMs(): number {
71
+ return Date.now();
72
+ }
73
+
74
+ function normalizeMarkdownAfterButtonExtraction(markdown: string): string {
75
+ return markdown.replace(/\n{3,}/g, "\n\n").trim();
76
+ }
77
+
78
+ function parseButtonsCommentAttributes(input: string): {
79
+ label?: string;
80
+ prompt?: string;
81
+ } {
82
+ const attributes = parseTelegramCommentAttributes(input);
83
+ return {
84
+ ...(attributes.label ? { label: attributes.label } : {}),
85
+ ...(attributes.prompt ? { prompt: attributes.prompt } : {}),
86
+ };
87
+ }
88
+
89
+ function parseButtonsCommentRows(
90
+ head: string,
91
+ body: string | undefined,
92
+ ): TelegramOutboundButtonAction[][] {
93
+ const trimmedHead = head.trim();
94
+
95
+ if (body === undefined) {
96
+ if (trimmedHead.startsWith(":")) {
97
+ const label = trimmedHead.slice(1).trim();
98
+ return label ? [[{ text: label, prompt: label }]] : [];
99
+ }
100
+ const attributes = parseButtonsCommentAttributes(head);
101
+ return attributes.label && attributes.prompt
102
+ ? [[{ text: attributes.label, prompt: attributes.prompt }]]
103
+ : [];
104
+ }
105
+
106
+ const label = parseButtonsCommentAttributes(head).label;
107
+ const prompt = body.trim();
108
+ if (!label || !prompt) return [];
109
+ return [[{ text: label, prompt }]];
110
+ }
111
+
112
+ export function createTelegramButtonActionStore(
113
+ options: { ttlMs?: number } = {},
114
+ ): TelegramButtonActionStore {
115
+ const ttlMs = options.ttlMs ?? TELEGRAM_BUTTON_ACTION_TTL_MS;
116
+ const actions = new Map<string, TelegramOutboundButtonStoredAction>();
117
+ function cleanup(currentTime: number): void {
118
+ for (const [key, action] of actions) {
119
+ if (currentTime - action.createdAt > ttlMs) actions.delete(key);
120
+ }
121
+ }
122
+ return {
123
+ register: (action) => {
124
+ const currentTime = nowMs();
125
+ cleanup(currentTime);
126
+ const key = `${TELEGRAM_BUTTON_CALLBACK_PREFIX}:${randomUUID().slice(0, 8)}`;
127
+ actions.set(key, { ...action, createdAt: currentTime });
128
+ return key;
129
+ },
130
+ resolve: (callbackData) => {
131
+ if (!callbackData?.startsWith(`${TELEGRAM_BUTTON_CALLBACK_PREFIX}:`)) {
132
+ return undefined;
133
+ }
134
+ const currentTime = nowMs();
135
+ cleanup(currentTime);
136
+ const action = actions.get(callbackData);
137
+ if (!action) return undefined;
138
+ return { text: action.text, prompt: action.prompt };
139
+ },
140
+ };
141
+ }
142
+
143
+ export function planTelegramButtonReply(
144
+ markdown: string,
145
+ deps: { registerAction: (action: TelegramOutboundButtonAction) => string },
146
+ ): TelegramButtonReplyPlan {
147
+ const keyboard: TelegramOutboundButtonMarkup["inline_keyboard"] = [];
148
+ const stripped = replaceTopLevelHtmlComments(markdown, (comment) => {
149
+ const command = parseTopLevelTelegramComment(comment, "telegram_button");
150
+ if (!command) return comment.raw;
151
+ const rows = parseButtonsCommentRows(command.head, command.body);
152
+ for (const row of rows) {
153
+ keyboard.push(
154
+ row.map((button) => ({
155
+ text: button.text,
156
+ callback_data: deps.registerAction(button),
157
+ })),
158
+ );
159
+ }
160
+ return "";
161
+ });
162
+ return {
163
+ markdown: normalizeMarkdownAfterButtonExtraction(stripped),
164
+ ...(keyboard.length > 0
165
+ ? { replyMarkup: { inline_keyboard: keyboard } }
166
+ : {}),
167
+ };
168
+ }
169
+
170
+ export function createTelegramButtonReplyPlanner(
171
+ store: Pick<TelegramButtonActionStore, "register">,
172
+ ): (markdown: string) => TelegramButtonReplyPlan {
173
+ return (markdown) =>
174
+ planTelegramButtonReply(markdown, { registerAction: store.register });
175
+ }
176
+
177
+ export function createTelegramButtonPromptTurn(options: {
178
+ chatId: number;
179
+ replyToMessageId: number;
180
+ queueOrder: number;
181
+ action: TelegramOutboundButtonAction;
182
+ }): PendingTelegramTurn {
183
+ const prompt = `[telegram] ${options.action.prompt}`;
184
+ return {
185
+ kind: "prompt",
186
+ chatId: options.chatId,
187
+ replyToMessageId: options.replyToMessageId,
188
+ sourceMessageIds: [options.replyToMessageId],
189
+ queueOrder: options.queueOrder,
190
+ queueLane: "default",
191
+ laneOrder: options.queueOrder,
192
+ queuedAttachments: [],
193
+ content: [{ type: "text", text: prompt }],
194
+ historyText: options.action.prompt,
195
+ statusSummary: truncateTelegramQueueSummary(
196
+ options.action.text || options.action.prompt,
197
+ ),
198
+ };
199
+ }
200
+
201
+ export async function handleTelegramButtonCallbackQuery<TContext = unknown>(
202
+ query: TelegramButtonCallbackQuery,
203
+ ctx: TContext,
204
+ deps: TelegramButtonCallbackHandlerDeps<TContext>,
205
+ ): Promise<boolean> {
206
+ const action = deps.resolveAction(query.data);
207
+
208
+ if (!action) {
209
+ if (query.data?.startsWith(`${TELEGRAM_BUTTON_CALLBACK_PREFIX}:`)) {
210
+ await deps.answerCallbackQuery(query.id, "Button action expired.");
211
+ return true;
212
+ }
213
+ return false;
214
+ }
215
+
216
+ const chatId = query.message?.chat?.id;
217
+ const messageId = query.message?.message_id;
218
+ if (typeof chatId !== "number" || typeof messageId !== "number") {
219
+ await deps.answerCallbackQuery(query.id, "Button action expired.");
220
+ return true;
221
+ }
222
+
223
+ deps.enqueueButtonPrompt(query, action, ctx);
224
+ await deps.answerCallbackQuery(query.id, "Queued.");
225
+ return true;
226
+ }
@@ -0,0 +1,357 @@
1
+ /**
2
+ * Telegram outbound markup parsing helpers
3
+ * Zones: telegram outbound, assistant markup
4
+ * Owns top-level assistant action comment extraction, attribute parsing, and markup stripping shared by voice and outbound delivery
5
+ */
6
+
7
+ export interface TelegramTopLevelHtmlComment {
8
+ raw: string;
9
+ content: string;
10
+ start: number;
11
+ end: number;
12
+ }
13
+
14
+ interface TelegramTopLevelFenceState {
15
+ marker: "`" | "~";
16
+ length: number;
17
+ }
18
+
19
+ function isTelegramActionCommentContent(content: string): boolean {
20
+ const normalizedContent = content.replace(/^\s+/, "");
21
+ const [head = ""] = normalizedContent.split(/\r?\n/, 1);
22
+ return ["telegram_voice", "telegram_button"].some((command) => {
23
+ if (!head.startsWith(command)) return false;
24
+ const nextChar = head[command.length];
25
+ return nextChar === undefined || /\s|:/.test(nextChar);
26
+ });
27
+ }
28
+
29
+ function getMarkdownLineEnd(markdown: string, offset: number): number {
30
+ const newlineIndex = markdown.indexOf("\n", offset);
31
+ return newlineIndex === -1 ? markdown.length : newlineIndex + 1;
32
+ }
33
+
34
+ function getMarkdownLineText(
35
+ markdown: string,
36
+ offset: number,
37
+ end: number,
38
+ ): string {
39
+ return markdown.slice(offset, end).replace(/\r?\n$/, "");
40
+ }
41
+
42
+ function getTopLevelOpeningFence(
43
+ line: string,
44
+ ): TelegramTopLevelFenceState | undefined {
45
+ const match = line.match(/^(?: {0,3})(`{3,}|~{3,})/);
46
+ const sequence = match?.[1];
47
+ if (!sequence) return undefined;
48
+ return {
49
+ marker: sequence[0] as "`" | "~",
50
+ length: sequence.length,
51
+ };
52
+ }
53
+
54
+ function isTopLevelClosingFence(
55
+ line: string,
56
+ fence: TelegramTopLevelFenceState,
57
+ ): boolean {
58
+ const match = line.match(/^(?: {0,3})(`{3,}|~{3,})([ \t]*)$/);
59
+ const sequence = match?.[1];
60
+ return (
61
+ !!sequence &&
62
+ sequence[0] === fence.marker &&
63
+ sequence.length >= fence.length
64
+ );
65
+ }
66
+
67
+ function collectInlineClosedTelegramActionBody(
68
+ markdown: string,
69
+ bodyStart: number,
70
+ commentContent: string,
71
+ ): { content: string; end: number } | undefined {
72
+ const bodyLineEnd = getMarkdownLineEnd(markdown, bodyStart);
73
+ const bodyLine = getMarkdownLineText(markdown, bodyStart, bodyLineEnd);
74
+ const closeLineEnd = getMarkdownLineEnd(markdown, bodyLineEnd);
75
+ const closeLine = getMarkdownLineText(markdown, bodyLineEnd, closeLineEnd);
76
+ const hasRecoverableBody =
77
+ isTelegramActionCommentContent(commentContent) &&
78
+ bodyLine.trim() !== "" &&
79
+ !bodyLine.startsWith("<!--") &&
80
+ !bodyLine.startsWith("-->") &&
81
+ closeLine === "-->";
82
+ if (!hasRecoverableBody) return undefined;
83
+ return {
84
+ content: `${commentContent.trimEnd()}\n${bodyLine}`,
85
+ end: bodyLineEnd + 3,
86
+ };
87
+ }
88
+
89
+ export function collectTopLevelHtmlComments(markdown: string): {
90
+ comments: TelegramTopLevelHtmlComment[];
91
+ openCommentStart?: number;
92
+ } {
93
+ const comments: TelegramTopLevelHtmlComment[] = [];
94
+ let offset = 0;
95
+ let fence: TelegramTopLevelFenceState | undefined;
96
+ while (offset < markdown.length) {
97
+ const lineEnd = getMarkdownLineEnd(markdown, offset);
98
+ const line = getMarkdownLineText(markdown, offset, lineEnd);
99
+ if (fence) {
100
+ if (isTopLevelClosingFence(line, fence)) fence = undefined;
101
+ offset = lineEnd;
102
+ continue;
103
+ }
104
+ const nextFence = getTopLevelOpeningFence(line);
105
+ if (nextFence) {
106
+ fence = nextFence;
107
+ offset = lineEnd;
108
+ continue;
109
+ }
110
+ if (line.startsWith("<!--")) {
111
+ const closeIndex = markdown.indexOf("-->", offset + 4);
112
+ if (closeIndex === -1) return { comments, openCommentStart: offset };
113
+ let end = closeIndex + 3;
114
+ let raw = markdown.slice(offset, end);
115
+ let content = raw.slice(4, -3);
116
+ const closeColumn = closeIndex - offset;
117
+ const closesOnOpeningLine = closeIndex < lineEnd;
118
+ const hasOnlyWhitespaceAfterClose =
119
+ line.slice(closeColumn + 3).trim() === "";
120
+ const inlineBody =
121
+ closesOnOpeningLine && hasOnlyWhitespaceAfterClose
122
+ ? collectInlineClosedTelegramActionBody(markdown, lineEnd, content)
123
+ : undefined;
124
+ if (inlineBody) {
125
+ end = inlineBody.end;
126
+ raw = markdown.slice(offset, end);
127
+ content = inlineBody.content;
128
+ }
129
+ comments.push({ raw, content, start: offset, end });
130
+ offset = getMarkdownLineEnd(markdown, end);
131
+ continue;
132
+ }
133
+ offset = lineEnd;
134
+ }
135
+ return { comments };
136
+ }
137
+
138
+ export function replaceTopLevelHtmlComments(
139
+ markdown: string,
140
+ replacer: (comment: TelegramTopLevelHtmlComment) => string,
141
+ ): string {
142
+ const { comments } = collectTopLevelHtmlComments(markdown);
143
+ if (comments.length === 0) return markdown;
144
+ let result = "";
145
+ let offset = 0;
146
+ for (const comment of comments) {
147
+ result += markdown.slice(offset, comment.start);
148
+ result += replacer(comment);
149
+ offset = comment.end;
150
+ }
151
+ return result + markdown.slice(offset);
152
+ }
153
+
154
+ export function findTopLevelOpenOrPartialHtmlCommentIndex(
155
+ markdown: string,
156
+ ): number {
157
+ const { openCommentStart } = collectTopLevelHtmlComments(markdown);
158
+ if (openCommentStart !== undefined) return openCommentStart;
159
+ let offset = 0;
160
+ let fence: TelegramTopLevelFenceState | undefined;
161
+ while (offset < markdown.length) {
162
+ const lineEnd = getMarkdownLineEnd(markdown, offset);
163
+ const line = getMarkdownLineText(markdown, offset, lineEnd);
164
+ const isLastLine = lineEnd >= markdown.length;
165
+ if (fence) {
166
+ if (isTopLevelClosingFence(line, fence)) fence = undefined;
167
+ offset = lineEnd;
168
+ continue;
169
+ }
170
+ const nextFence = getTopLevelOpeningFence(line);
171
+ if (nextFence) {
172
+ fence = nextFence;
173
+ offset = lineEnd;
174
+ continue;
175
+ }
176
+ if (isLastLine && (line === "<" || line === "<!" || line === "<!-")) {
177
+ return offset;
178
+ }
179
+ offset = lineEnd;
180
+ }
181
+ return -1;
182
+ }
183
+
184
+ export function parseTopLevelTelegramComment(
185
+ comment: TelegramTopLevelHtmlComment,
186
+ command: string,
187
+ ): { head: string; body?: string } | undefined {
188
+ let normalizedContent = comment.content.replace(/^\s+/, "");
189
+ normalizedContent = normalizedContent.replace(/^!/, "");
190
+ const [rawHead = "", ...bodyLines] = normalizedContent.split(/\r?\n/);
191
+ let head = rawHead.trimStart();
192
+ if (!head.startsWith(command)) return undefined;
193
+ const nextChar = head[command.length];
194
+ if (nextChar !== undefined && !/\s|:/.test(nextChar)) return undefined;
195
+ return {
196
+ head: head.slice(command.length),
197
+ ...(bodyLines.length > 0 ? { body: bodyLines.join("\n") } : {}),
198
+ };
199
+ }
200
+
201
+ export function parseTelegramCommentAttributes(
202
+ input: string,
203
+ ): Record<string, string> {
204
+ const attributes: Record<string, string> = {};
205
+ for (const match of input.matchAll(
206
+ /([A-Za-z_][A-Za-z0-9_-]*)=(?:"([^"]*)"|'([^']*)'|(\S+))/g,
207
+ )) {
208
+ const key = match[1];
209
+ const value = (match[2] ?? match[3] ?? match[4] ?? "").trim();
210
+ if (value) attributes[key] = value;
211
+ }
212
+ return attributes;
213
+ }
214
+
215
+ export function normalizeMarkdownAfterVoiceExtraction(
216
+ markdown: string,
217
+ ): string {
218
+ return markdown.replace(/\n{3,}/g, "\n\n").trim();
219
+ }
220
+
221
+ export function stripTelegramCommentMarkupForPreview(markdown: string): string {
222
+ const withoutClosedBlocks = replaceTopLevelHtmlComments(markdown, () => "");
223
+ const openBlockIndex =
224
+ findTopLevelOpenOrPartialHtmlCommentIndex(withoutClosedBlocks);
225
+ const previewMarkdown =
226
+ openBlockIndex >= 0
227
+ ? withoutClosedBlocks.slice(0, openBlockIndex)
228
+ : withoutClosedBlocks;
229
+ return normalizeMarkdownAfterVoiceExtraction(previewMarkdown);
230
+ }
231
+
232
+ export function stripTelegramCommentMarkupForDelivery(
233
+ markdown: string,
234
+ ): string {
235
+ const withoutClosedBlocks = replaceTopLevelHtmlComments(markdown, () => "");
236
+ const openBlockIndex =
237
+ findTopLevelOpenOrPartialHtmlCommentIndex(withoutClosedBlocks);
238
+ const deliveryMarkdown =
239
+ openBlockIndex >= 0
240
+ ? withoutClosedBlocks.slice(0, openBlockIndex)
241
+ : withoutClosedBlocks;
242
+ return normalizeMarkdownAfterVoiceExtraction(deliveryMarkdown);
243
+ }
244
+
245
+ export function stripTelegramVoiceMarkupForPreview(markdown: string): string {
246
+ return stripTelegramCommentMarkupForPreview(markdown);
247
+ }
248
+
249
+ export interface TelegramVoiceReplyItem {
250
+ text: string;
251
+ lang?: string;
252
+ rate?: string;
253
+ }
254
+
255
+ export interface TelegramVoiceReplyPlan {
256
+ markdown: string;
257
+ voiceText?: string;
258
+ voiceReplies?: TelegramVoiceReplyItem[];
259
+ lang?: string;
260
+ rate?: string;
261
+ }
262
+
263
+ function parseVoiceReplyAttributes(input: string): {
264
+ lang?: string;
265
+ rate?: string;
266
+ text?: string;
267
+ } {
268
+ const attributes = parseTelegramCommentAttributes(input);
269
+ return {
270
+ ...(attributes.lang ? { lang: attributes.lang } : {}),
271
+ ...(attributes.rate ? { rate: attributes.rate } : {}),
272
+ ...(attributes.text ? { text: attributes.text } : {}),
273
+ };
274
+ }
275
+
276
+ function parseVoiceCommentBody(
277
+ head: string,
278
+ body: string | undefined,
279
+ ): {
280
+ attrs: string;
281
+ text: string;
282
+ } {
283
+ const trimmedHead = head.trim();
284
+ if (body !== undefined) {
285
+ return { attrs: trimmedHead.replace(/^:/, "").trim(), text: body.trim() };
286
+ }
287
+ let colonIndex = -1;
288
+ let inQuote = false;
289
+ let quoteChar = "";
290
+ for (let i = 0; i < trimmedHead.length; i++) {
291
+ const char = trimmedHead[i];
292
+ if (inQuote) {
293
+ if (char === quoteChar) inQuote = false;
294
+ } else {
295
+ if (char === '"' || char === "'") {
296
+ inQuote = true;
297
+ quoteChar = char;
298
+ } else if (char === ":") {
299
+ colonIndex = i;
300
+ break;
301
+ }
302
+ }
303
+ }
304
+ if (colonIndex > 0) {
305
+ const attrsPart = trimmedHead.slice(0, colonIndex).trim();
306
+ const textPart = trimmedHead.slice(colonIndex + 1).trim();
307
+ const attrs = parseVoiceReplyAttributes(attrsPart);
308
+ return { attrs: attrsPart, text: textPart || attrs.text || "", ...attrs };
309
+ }
310
+ if (trimmedHead.startsWith(":")) {
311
+ return { attrs: "", text: trimmedHead.slice(1).trim() };
312
+ }
313
+ const attrs = parseVoiceReplyAttributes(trimmedHead);
314
+ return { attrs: trimmedHead, text: attrs.text ?? "" };
315
+ }
316
+
317
+ export function planTelegramVoiceReply(
318
+ markdown: string,
319
+ ): TelegramVoiceReplyPlan {
320
+ const voiceReplies: TelegramVoiceReplyItem[] = [];
321
+ let lang: string | undefined;
322
+ let rate: string | undefined;
323
+ const stripped = replaceTopLevelHtmlComments(markdown, (comment) => {
324
+ let command = parseTopLevelTelegramComment(comment, "telegram_voice");
325
+ if (!command) {
326
+ let content = comment.content.replace(/^\s+/, "").replace(/^!/, "");
327
+ if (content.startsWith("telegram_voice")) {
328
+ const headPart = content.slice("telegram_voice".length).trim();
329
+ command = { head: headPart, body: undefined };
330
+ }
331
+ }
332
+ if (!command) return "";
333
+ const parsed = parseVoiceCommentBody(command.head, command.body);
334
+ const attrs = parseVoiceReplyAttributes(parsed.attrs);
335
+ if (parsed.text) {
336
+ voiceReplies.push({
337
+ text: parsed.text,
338
+ ...(attrs.lang ? { lang: attrs.lang } : {}),
339
+ ...(attrs.rate ? { rate: attrs.rate } : {}),
340
+ });
341
+ }
342
+ if (attrs.lang) lang = attrs.lang;
343
+ if (attrs.rate) rate = attrs.rate;
344
+ return "";
345
+ });
346
+ const voiceText = voiceReplies
347
+ .map((reply) => reply.text)
348
+ .join("\n\n")
349
+ .trim();
350
+ return {
351
+ markdown: stripTelegramCommentMarkupForDelivery(stripped),
352
+ ...(voiceText ? { voiceText } : {}),
353
+ ...(voiceReplies.length > 0 ? { voiceReplies } : {}),
354
+ ...(lang ? { lang } : {}),
355
+ ...(rate ? { rate } : {}),
356
+ };
357
+ }