switchroom 0.18.13 → 0.18.15
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/agent-scheduler/index.js +49 -9
- package/dist/auth-broker/index.js +152 -46
- package/dist/cli/autoaccept-poll.js +23 -0
- package/dist/cli/drive-write-pretool.mjs +24 -1
- package/dist/cli/foreground-hog-pretool.mjs +264 -0
- package/dist/cli/notion-write-pretool.mjs +0 -1
- package/dist/cli/switchroom.js +1185 -1072
- package/dist/host-control/main.js +53 -52
- package/dist/vault/approvals/kernel-server.js +16 -13
- package/dist/vault/broker/server.js +672 -669
- package/package.json +1 -1
- package/profiles/coding/CLAUDE.md.hbs +2 -0
- package/profiles/default/CLAUDE.md.hbs +2 -0
- package/skills/switchroom-architecture/telegram.md +0 -1
- package/telegram-plugin/auth-snapshot-format.ts +37 -5
- package/telegram-plugin/auto-fallback-fleet.ts +29 -1
- package/telegram-plugin/bridge/bridge.ts +2 -0
- package/telegram-plugin/dist/bridge/bridge.js +23 -0
- package/telegram-plugin/dist/gateway/gateway.js +765 -67
- package/telegram-plugin/dist/server.js +24 -1
- package/telegram-plugin/gateway/auth-broker-client.ts +1 -0
- package/telegram-plugin/gateway/auth-command.ts +14 -0
- package/telegram-plugin/gateway/forward-origin.ts +235 -0
- package/telegram-plugin/gateway/gateway.ts +270 -10
- package/telegram-plugin/gateway/throttle-tier-wiring.ts +268 -0
- package/telegram-plugin/history.ts +55 -6
- package/telegram-plugin/model-unavailable.ts +234 -2
- package/telegram-plugin/render/rich-render.ts +40 -32
- package/telegram-plugin/runtime-metrics.ts +31 -0
- package/telegram-plugin/session-tail.ts +14 -2
- package/telegram-plugin/stream-controller.ts +3 -2
- package/telegram-plugin/tests/auto-fallback-fleet.test.ts +72 -0
- package/telegram-plugin/tests/forward-origin.test.ts +309 -0
- package/telegram-plugin/tests/history.test.ts +157 -0
- package/telegram-plugin/tests/model-unavailable.test.ts +187 -0
- package/telegram-plugin/tests/operator-events-session-tail.test.ts +55 -0
- package/telegram-plugin/tests/render/render-outbound-chunks.test.ts +6 -4
- package/telegram-plugin/tests/render/rich-render.test.ts +41 -22
- package/telegram-plugin/tests/runtime-metrics.test.ts +24 -0
- package/telegram-plugin/tests/single-mode-stream-reply.test.ts +5 -3
- package/telegram-plugin/tests/status-accent.test.ts +5 -3
- package/telegram-plugin/tests/stream-controller-chunk-cap.test.ts +20 -20
- package/telegram-plugin/tests/stream-reply-handler.test.ts +5 -2
- package/telegram-plugin/tests/throttle-tier-wiring.test.ts +290 -0
- package/telegram-plugin/tests/throttle-tier.test.ts +454 -0
- package/telegram-plugin/throttle-tier.ts +323 -0
- package/telegram-plugin/uat/scenarios/jtbd-rich-formatting-render-dm.test.ts +8 -7
|
@@ -17088,7 +17088,16 @@ function isTransientUpstreamSignal(text) {
|
|
|
17088
17088
|
const lower = sample.toLowerCase();
|
|
17089
17089
|
return transientUpstreamSignals.some((s) => lower.includes(s));
|
|
17090
17090
|
}
|
|
17091
|
-
|
|
17091
|
+
function isLitellmProxyLocal429(text) {
|
|
17092
|
+
if (typeof text !== "string" || text.length === 0)
|
|
17093
|
+
return false;
|
|
17094
|
+
const sample = text.length > 16384 ? text.slice(0, 16384) : text;
|
|
17095
|
+
const lower = sample.toLowerCase();
|
|
17096
|
+
if (litellmProxyLocal429Signals.some((s) => lower.includes(s)))
|
|
17097
|
+
return true;
|
|
17098
|
+
return litellmV3LimiterSignalPair.every((s) => lower.includes(s));
|
|
17099
|
+
}
|
|
17100
|
+
var transientUpstreamSignals, litellmProxyLocal429Signals, litellmV3LimiterSignalPair;
|
|
17092
17101
|
var init_model_unavailable = __esm(() => {
|
|
17093
17102
|
init_quota_check();
|
|
17094
17103
|
init_card_format();
|
|
@@ -17102,6 +17111,17 @@ var init_model_unavailable = __esm(() => {
|
|
|
17102
17111
|
"would exceed your account\u2019s rate limit",
|
|
17103
17112
|
"would exceed your account's rate limit"
|
|
17104
17113
|
];
|
|
17114
|
+
litellmProxyLocal429Signals = [
|
|
17115
|
+
"deployment over user-defined ratelimit",
|
|
17116
|
+
"model rate limit exceeded. tpm limit",
|
|
17117
|
+
"model rate limit exceeded. rpm limit",
|
|
17118
|
+
"deployment over defined rpm limit",
|
|
17119
|
+
"no deployments available for selected model",
|
|
17120
|
+
"litellm rate limit handler",
|
|
17121
|
+
"crossed tpm / rpm",
|
|
17122
|
+
"max parallel request limit reached"
|
|
17123
|
+
];
|
|
17124
|
+
litellmV3LimiterSignalPair = ["rate limit exceeded for ", "limit type:"];
|
|
17105
17125
|
});
|
|
17106
17126
|
|
|
17107
17127
|
// tool-label-sidecar.ts
|
|
@@ -17534,6 +17554,7 @@ function detectErrorInTranscriptLine(line) {
|
|
|
17534
17554
|
const errStr = typeof obj.error === "string" ? obj.error : "";
|
|
17535
17555
|
const text = extractAssistantText(obj);
|
|
17536
17556
|
const kind2 = status === 429 ? isTransientUpstreamSignal(`${text}
|
|
17557
|
+
${errStr}`) || isLitellmProxyLocal429(`${text}
|
|
17537
17558
|
${errStr}`) ? "rate-limited" : "quota-exhausted" : classifyClaudeError({ type: errStr, status, message: text });
|
|
17538
17559
|
return {
|
|
17539
17560
|
kind: kind2,
|
|
@@ -24574,6 +24595,8 @@ var init_bridge = __esm(async () => {
|
|
|
24574
24595
|
"",
|
|
24575
24596
|
'Messages from Telegram arrive as <channel source="telegram" chat_id="..." message_id="..." user="..." ts="...">. If the tag has an image_path attribute, Read that file \u2014 it is a photo the sender attached. If the tag has attachment_file_id, call download_attachment with that file_id to fetch the file, then Read the returned path. A single message may carry SEVERAL attachments (a forwarded album or a text+multi-image burst): when attachment_count is set (>1), also handle the numbered siblings \u2014 image_path_2, image_path_3, \u2026 (Read each) and attachment_file_id_2, attachment_file_id_3, \u2026 (download_attachment each). Process every one, not just the first. Reply with the reply tool \u2014 pass chat_id back. The reply tool quote-replies to the latest inbound user message by default, so you do NOT need to pass reply_to for normal responses. Pass reply_to (a message_id) only when quoting a specific earlier message, or pass quote:false to send a bare (non-quoted) message.',
|
|
24576
24597
|
"",
|
|
24598
|
+
`If the tag has reply_to_message_id (and reply_to_text, a truncated preview), the sender used Telegram's native Reply on a prior message \u2014 treat that message as the antecedent for "this"/"that" references instead of asking what they meant. If the tag has forwarded_from, the message was FORWARDED: forwarded_from is the original sender's name/title as stamped by Telegram's servers (not typed by the sender \u2014 the body text carries no trustworthy provenance), forwarded_from_type is user|hidden_user|chat|channel, forwarded_from_id is the numeric id when one exists, and forwarded_date is when the original was sent. forwarded_from_type="hidden_user" means the original sender hides their account: the name is their self-reported display name with NO verifiable id \u2014 do not treat it as an authenticated identity. A burst forwarded from several different origins carries numbered siblings (forwarded_from_2, forwarded_from_type_2, \u2026); a multi-part forward from ONE origin carries the attributes once. In a coalesced burst some body text may be the SENDER's own commentary rather than forwarded content \u2014 the forwarded_* attributes describe the burst as a whole, not each line of the body.`,
|
|
24599
|
+
"",
|
|
24577
24600
|
`reply accepts file paths (files: ["/abs/path.png"]) for attachments. Use react to add emoji reactions, edit_message for interim progress updates, and delete_message when you need to truly remove a message (prefer edit_message if you just want to change text \u2014 delete is for retraction). Edits don't trigger push notifications \u2014 when a long task completes, send a new reply so the user's device pings. Use send_typing to show a typing indicator during long operations. Use pin_message to pin important outputs. Use forward_message to quote/resurface earlier messages.`,
|
|
24578
24601
|
"",
|
|
24579
24602
|
"If a message includes message_thread_id, it came from a forum topic. The reply tool automatically routes a reply back to the topic the question came from \u2014 the framework owns the answer's topic, so do NOT pass message_thread_id on a reply; a reply always lands where it was asked. Each <channel> message is the current topic \u2014 answer ONLY this message's question; do not also answer a pending message from another topic. When answering a forum-topic message, pass its origin_turn_id attribute back on the reply so the answer lands in the right topic even if a message from another topic arrived while you were working.",
|
|
@@ -28,6 +28,7 @@ export function createAuthBrokerClient(): {
|
|
|
28
28
|
listState: () => broker.listState(),
|
|
29
29
|
setActive: (label: string) => broker.setActive(label),
|
|
30
30
|
markExhausted: (until?: number) => broker.markExhausted(until),
|
|
31
|
+
markThrottled: (until: number) => broker.markThrottled(until),
|
|
31
32
|
rmAccount: (label: string) => broker.rmAccount(label),
|
|
32
33
|
refreshAccount: (label: string) => broker.refreshAccount(label),
|
|
33
34
|
setOverride: (agent: string, account: string | null) =>
|
|
@@ -301,6 +301,20 @@ export interface AuthBrokerClient {
|
|
|
301
301
|
* identity — so auto-fallback works from any agent.
|
|
302
302
|
*/
|
|
303
303
|
markExhausted(until?: number): Promise<{ account: string; rolled: string[]; rolledTo?: string | null }>
|
|
304
|
+
/**
|
|
305
|
+
* 429 throttle tier (broker `mark-throttled`). Records a transient
|
|
306
|
+
* per-account rate limit on the CALLER's own account — `throttled_until`
|
|
307
|
+
* in the quota ledger — WITHOUT rolling the fleet and WITHOUT touching
|
|
308
|
+
* eligibility. `escalated` is true when the broker's escalation guard
|
|
309
|
+
* (repeated hits corroborated by a live probe) converted it into the
|
|
310
|
+
* standard mark-exhausted + roll; `rolledTo` names the roll target then.
|
|
311
|
+
*/
|
|
312
|
+
markThrottled(until: number): Promise<{
|
|
313
|
+
account: string
|
|
314
|
+
throttled_until: number
|
|
315
|
+
escalated: boolean
|
|
316
|
+
rolledTo?: string | null
|
|
317
|
+
}>
|
|
304
318
|
rmAccount(label: string): Promise<{ label: string }>
|
|
305
319
|
refreshAccount(label: string): Promise<{ account: string; expiresAt?: number }>
|
|
306
320
|
setOverride(
|
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure helpers for forwarded-message origin metadata — kept out of
|
|
3
|
+
* `gateway.ts` (same pattern as `coalesce-attachments.ts`) so the parsing,
|
|
4
|
+
* escaping, and numbered-sibling logic can be unit-tested without the
|
|
5
|
+
* gateway's `loadAccess()` / IPC machinery.
|
|
6
|
+
*
|
|
7
|
+
* When a user forwards a message to the bot, Telegram stamps
|
|
8
|
+
* `message.forward_origin` SERVER-side (Bot API 7.0+). Unlike the message
|
|
9
|
+
* body — which the forwarding user fully controls — the origin record
|
|
10
|
+
* cannot be forged by typing, so it is the trusted lane for "who
|
|
11
|
+
* originally sent this". The gateway surfaces it to the agent as
|
|
12
|
+
* `forwarded_*` channel-meta ATTRIBUTES only; origin info is never
|
|
13
|
+
* injected into the body text, keeping the attacker-influenceable body
|
|
14
|
+
* and the server-stamped provenance on separate lanes.
|
|
15
|
+
*
|
|
16
|
+
* Caveat surfaced via `forwarded_from_type="hidden_user"`: a sender who
|
|
17
|
+
* enabled forward privacy yields only a self-reported display name with
|
|
18
|
+
* NO verifiable id — agents can see the name but must not treat it as an
|
|
19
|
+
* authenticated identity.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import type { MessageOrigin } from 'grammy/types'
|
|
23
|
+
import { escapeXmlAttribute } from '../steering.js'
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Cap on the human-readable origin name/title. Names and titles are
|
|
27
|
+
* attacker-controlled (a hostile account can set a 4KB display name);
|
|
28
|
+
* mirror the REPLY_TO_TEXT_MAX truncate-then-escape pattern with a cap
|
|
29
|
+
* sized for names rather than message previews.
|
|
30
|
+
*/
|
|
31
|
+
export const FORWARDED_FROM_NAME_MAX = 100
|
|
32
|
+
|
|
33
|
+
export type ForwardOriginType = 'user' | 'hidden_user' | 'chat' | 'channel'
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Normalized origin record. `name` is RAW (truncated but unescaped) so it
|
|
37
|
+
* can be persisted verbatim to the SQLite history buffer — the XML
|
|
38
|
+
* escaping happens at the channel-meta boundary in
|
|
39
|
+
* `buildForwardOriginMeta`, same split as `replyToText` /
|
|
40
|
+
* `replyToTextEscaped` in the gateway inbound handler.
|
|
41
|
+
*/
|
|
42
|
+
export interface ForwardOriginInfo {
|
|
43
|
+
/** Human-readable name/title of the original sender (raw, truncated). */
|
|
44
|
+
name: string
|
|
45
|
+
type: ForwardOriginType
|
|
46
|
+
/** Numeric id when the origin shape exposes one (user id / chat id). */
|
|
47
|
+
id?: number
|
|
48
|
+
/** Unix seconds of the original message (`forward_origin.date`). */
|
|
49
|
+
date?: number
|
|
50
|
+
/** Channel origins only: the message id inside the origin channel. */
|
|
51
|
+
messageId?: number
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function truncateName(name: string): string {
|
|
55
|
+
return name.length > FORWARDED_FROM_NAME_MAX
|
|
56
|
+
? name.slice(0, FORWARDED_FROM_NAME_MAX - 1) + '…'
|
|
57
|
+
: name
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Join first/last name plus a trailing `(@username)` when present. */
|
|
61
|
+
function personName(parts: {
|
|
62
|
+
first_name?: string
|
|
63
|
+
last_name?: string
|
|
64
|
+
username?: string
|
|
65
|
+
}): string {
|
|
66
|
+
const name = [parts.first_name, parts.last_name]
|
|
67
|
+
.filter((p): p is string => typeof p === 'string' && p.length > 0)
|
|
68
|
+
.join(' ')
|
|
69
|
+
const handle = parts.username ? `(@${parts.username})` : ''
|
|
70
|
+
return [name, handle].filter((p) => p.length > 0).join(' ')
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Chat/channel title plus a trailing `(@username)` when present. */
|
|
74
|
+
function chatTitle(chat: {
|
|
75
|
+
title?: string
|
|
76
|
+
username?: string
|
|
77
|
+
first_name?: string
|
|
78
|
+
last_name?: string
|
|
79
|
+
}): string {
|
|
80
|
+
// Defensive: `Chat` is a union — groups/channels carry `title`, a
|
|
81
|
+
// private chat carries first/last name instead. Fall through so a
|
|
82
|
+
// malformed/unexpected shape still yields whatever name exists.
|
|
83
|
+
if (chat.title && chat.title.length > 0) {
|
|
84
|
+
const handle = chat.username ? `(@${chat.username})` : ''
|
|
85
|
+
return [chat.title, handle].filter((p) => p.length > 0).join(' ')
|
|
86
|
+
}
|
|
87
|
+
return personName(chat)
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Parse Telegram's `message.forward_origin` into a normalized record.
|
|
92
|
+
* Returns `undefined` for non-forwarded messages, unknown origin types,
|
|
93
|
+
* and records so malformed that no human-readable name can be recovered
|
|
94
|
+
* (the attrs are context, not a gate — degrading to "no origin metadata"
|
|
95
|
+
* is safe; inventing a name is not).
|
|
96
|
+
*
|
|
97
|
+
* Accepts `unknown`-ish input defensively: the origin arrives from the
|
|
98
|
+
* Telegram wire and future Bot API versions may add origin types this
|
|
99
|
+
* build doesn't know.
|
|
100
|
+
*/
|
|
101
|
+
export function parseForwardOrigin(
|
|
102
|
+
origin: MessageOrigin | undefined,
|
|
103
|
+
): ForwardOriginInfo | undefined {
|
|
104
|
+
if (origin == null || typeof origin !== 'object') return undefined
|
|
105
|
+
const date = typeof origin.date === 'number' ? origin.date : undefined
|
|
106
|
+
switch (origin.type) {
|
|
107
|
+
case 'user': {
|
|
108
|
+
const u = origin.sender_user
|
|
109
|
+
if (u == null || typeof u !== 'object') return undefined
|
|
110
|
+
const name = personName(u)
|
|
111
|
+
if (name.length === 0) {
|
|
112
|
+
// No printable name at all — fall back to the (unforgeable) id.
|
|
113
|
+
if (typeof u.id !== 'number') return undefined
|
|
114
|
+
return { name: String(u.id), type: 'user', id: u.id, date }
|
|
115
|
+
}
|
|
116
|
+
return {
|
|
117
|
+
name: truncateName(name),
|
|
118
|
+
type: 'user',
|
|
119
|
+
...(typeof u.id === 'number' ? { id: u.id } : {}),
|
|
120
|
+
date,
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
case 'hidden_user': {
|
|
124
|
+
// Forward-privacy senders: `sender_user_name` is a SELF-REPORTED
|
|
125
|
+
// display name with no verifiable id. The `hidden_user` type marker
|
|
126
|
+
// is the agent's signal to treat the name as unauthenticated.
|
|
127
|
+
const name = typeof origin.sender_user_name === 'string'
|
|
128
|
+
? origin.sender_user_name
|
|
129
|
+
: ''
|
|
130
|
+
if (name.length === 0) return undefined
|
|
131
|
+
return { name: truncateName(name), type: 'hidden_user', date }
|
|
132
|
+
}
|
|
133
|
+
case 'chat': {
|
|
134
|
+
const c = origin.sender_chat
|
|
135
|
+
if (c == null || typeof c !== 'object') return undefined
|
|
136
|
+
const name = chatTitle(c)
|
|
137
|
+
if (name.length === 0) {
|
|
138
|
+
if (typeof c.id !== 'number') return undefined
|
|
139
|
+
return { name: String(c.id), type: 'chat', id: c.id, date }
|
|
140
|
+
}
|
|
141
|
+
return {
|
|
142
|
+
name: truncateName(name),
|
|
143
|
+
type: 'chat',
|
|
144
|
+
...(typeof c.id === 'number' ? { id: c.id } : {}),
|
|
145
|
+
date,
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
case 'channel': {
|
|
149
|
+
const c = origin.chat
|
|
150
|
+
if (c == null || typeof c !== 'object') return undefined
|
|
151
|
+
const name = chatTitle(c)
|
|
152
|
+
const messageId = typeof origin.message_id === 'number' ? origin.message_id : undefined
|
|
153
|
+
if (name.length === 0) {
|
|
154
|
+
if (typeof c.id !== 'number') return undefined
|
|
155
|
+
return { name: String(c.id), type: 'channel', id: c.id, date, messageId }
|
|
156
|
+
}
|
|
157
|
+
return {
|
|
158
|
+
name: truncateName(name),
|
|
159
|
+
type: 'channel',
|
|
160
|
+
...(typeof c.id === 'number' ? { id: c.id } : {}),
|
|
161
|
+
date,
|
|
162
|
+
messageId,
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
default:
|
|
166
|
+
// Unknown future origin type — no metadata beats wrong metadata.
|
|
167
|
+
return undefined
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Identity key for burst dedup: same-typed origins with the same id are
|
|
173
|
+
* one origin; id-less shapes (hidden_user) fall back to the name.
|
|
174
|
+
*/
|
|
175
|
+
export function forwardOriginKey(o: ForwardOriginInfo): string {
|
|
176
|
+
return o.id != null ? `${o.type}:${o.id}` : `${o.type}:${o.name}`
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Collapse the per-message origins of a coalesced burst into the DISTINCT
|
|
181
|
+
* origins in arrival order. A 10-part album forwarded from one channel
|
|
182
|
+
* yields ONE origin (attrs emitted once); a burst mixing forwards from two
|
|
183
|
+
* different senders yields two (the second gets `forwarded_from_2` etc.).
|
|
184
|
+
* Non-forwarded entries (`undefined`) are skipped. First occurrence wins,
|
|
185
|
+
* so the emitted `forwarded_date` is the first message's origin date.
|
|
186
|
+
*/
|
|
187
|
+
export function dedupeForwardOrigins(
|
|
188
|
+
origins: Array<ForwardOriginInfo | undefined>,
|
|
189
|
+
): ForwardOriginInfo[] {
|
|
190
|
+
const seen = new Set<string>()
|
|
191
|
+
const out: ForwardOriginInfo[] = []
|
|
192
|
+
for (const o of origins) {
|
|
193
|
+
if (o == null) continue
|
|
194
|
+
const key = forwardOriginKey(o)
|
|
195
|
+
if (seen.has(key)) continue
|
|
196
|
+
seen.add(key)
|
|
197
|
+
out.push(o)
|
|
198
|
+
}
|
|
199
|
+
return out
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* Build the `forwarded_*` channel-meta fields. Fixed per-origin attribute
|
|
204
|
+
* order (documented here, tested in forward-origin.test.ts):
|
|
205
|
+
* forwarded_from, forwarded_from_type, forwarded_from_id, forwarded_date
|
|
206
|
+
* The primary field is the human-readable NAME; the numeric id is
|
|
207
|
+
* supplementary and follows it. The first origin gets the bare keys;
|
|
208
|
+
* subsequent distinct origins get `_2`, `_3`, … suffixes — the same
|
|
209
|
+
* numbered-sibling convention as `image_path_2` / `attachment_file_id_2`.
|
|
210
|
+
*
|
|
211
|
+
* Names/titles are attacker-controlled, so every value that can carry
|
|
212
|
+
* user text goes through `escapeXmlAttribute` (the same escaper
|
|
213
|
+
* `formatReplyToText` uses) before landing in the channel tag.
|
|
214
|
+
*/
|
|
215
|
+
export function buildForwardOriginMeta(
|
|
216
|
+
origins: ForwardOriginInfo[],
|
|
217
|
+
): Record<string, string> {
|
|
218
|
+
const out: Record<string, string> = {}
|
|
219
|
+
origins.forEach((o, i) => {
|
|
220
|
+
const suffix = i === 0 ? '' : `_${i + 1}`
|
|
221
|
+
out[`forwarded_from${suffix}`] = escapeXmlAttribute(truncateName(o.name))
|
|
222
|
+
out[`forwarded_from_type${suffix}`] = o.type
|
|
223
|
+
if (o.id != null) out[`forwarded_from_id${suffix}`] = String(o.id)
|
|
224
|
+
if (o.date != null) {
|
|
225
|
+
out[`forwarded_date${suffix}`] = new Date(o.date * 1000).toISOString()
|
|
226
|
+
}
|
|
227
|
+
})
|
|
228
|
+
return out
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/** ISO form of an origin date for the SQLite history record. */
|
|
232
|
+
export function forwardOriginDateIso(o: ForwardOriginInfo | undefined): string | null {
|
|
233
|
+
if (o?.date == null) return null
|
|
234
|
+
return new Date(o.date * 1000).toISOString()
|
|
235
|
+
}
|