@gajae-code/coding-agent 0.6.5 → 0.7.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/CHANGELOG.md +38 -0
- package/dist/types/async/job-manager.d.ts +3 -1
- package/dist/types/cli/daemon-cli.d.ts +25 -0
- package/dist/types/cli/notify-cli.d.ts +23 -0
- package/dist/types/cli/setup-cli.d.ts +20 -1
- package/dist/types/commands/daemon.d.ts +41 -0
- package/dist/types/commands/notify.d.ts +41 -0
- package/dist/types/config/model-profile-activation.d.ts +12 -0
- package/dist/types/config/model-profiles.d.ts +2 -1
- package/dist/types/config/model-registry.d.ts +3 -3
- package/dist/types/config/models-config-schema.d.ts +5 -0
- package/dist/types/config/settings-schema.d.ts +38 -0
- package/dist/types/coordinator/contract.d.ts +1 -1
- package/dist/types/daemon/builtin.d.ts +20 -0
- package/dist/types/daemon/control-types.d.ts +57 -0
- package/dist/types/daemon/runtime.d.ts +25 -0
- package/dist/types/extensibility/extensions/types.d.ts +8 -0
- package/dist/types/gjc-runtime/launch-tmux.d.ts +1 -0
- package/dist/types/gjc-runtime/state-writer.d.ts +2 -0
- package/dist/types/gjc-runtime/tmux-common.d.ts +3 -0
- package/dist/types/gjc-runtime/tmux-sessions.d.ts +2 -0
- package/dist/types/gjc-runtime/ultragoal-guard.d.ts +15 -0
- package/dist/types/gjc-runtime/ultragoal-runtime.d.ts +14 -0
- package/dist/types/modes/components/oauth-selector.d.ts +2 -0
- package/dist/types/modes/controllers/selector-controller.d.ts +2 -2
- package/dist/types/modes/interactive-mode.d.ts +1 -1
- package/dist/types/modes/shared/agent-wire/unattended-session.d.ts +10 -0
- package/dist/types/modes/types.d.ts +7 -1
- package/dist/types/notifications/config-commands.d.ts +26 -0
- package/dist/types/notifications/config.d.ts +61 -0
- package/dist/types/notifications/helpers.d.ts +55 -0
- package/dist/types/notifications/html-format.d.ts +62 -0
- package/dist/types/notifications/index.d.ts +28 -0
- package/dist/types/notifications/rate-limit-pool.d.ts +93 -0
- package/dist/types/notifications/telegram-cli.d.ts +19 -0
- package/dist/types/notifications/telegram-daemon-cli.d.ts +11 -0
- package/dist/types/notifications/telegram-daemon-control.d.ts +56 -0
- package/dist/types/notifications/telegram-daemon.d.ts +276 -0
- package/dist/types/notifications/telegram-reference.d.ts +111 -0
- package/dist/types/notifications/threaded-inbound.d.ts +58 -0
- package/dist/types/notifications/threaded-render.d.ts +66 -0
- package/dist/types/notifications/topic-registry.d.ts +67 -0
- package/dist/types/rlm/index.d.ts +12 -0
- package/dist/types/session/agent-session.d.ts +39 -2
- package/dist/types/session/auth-storage.d.ts +1 -1
- package/dist/types/setup/credential-auto-import.d.ts +63 -0
- package/dist/types/setup/credential-import.d.ts +3 -0
- package/dist/types/setup/host-plugin-setup.d.ts +39 -0
- package/dist/types/tools/ask-answer-registry.d.ts +13 -0
- package/dist/types/tools/index.d.ts +18 -0
- package/dist/types/tools/subagent.d.ts +3 -0
- package/package.json +7 -7
- package/scripts/build-binary.ts +3 -0
- package/src/async/job-manager.ts +5 -1
- package/src/cli/daemon-cli.ts +122 -0
- package/src/cli/notify-cli.ts +274 -0
- package/src/cli/setup-cli.ts +173 -84
- package/src/cli.ts +3 -3
- package/src/commands/daemon.ts +47 -0
- package/src/commands/notify.ts +61 -0
- package/src/commands/setup.ts +11 -1
- package/src/config/model-profile-activation.ts +74 -5
- package/src/config/model-profiles.ts +7 -4
- package/src/config/model-registry.ts +6 -3
- package/src/config/models-config-schema.ts +1 -1
- package/src/config/settings-schema.ts +29 -0
- package/src/coordinator/contract.ts +3 -0
- package/src/coordinator-mcp/server.ts +270 -1
- package/src/daemon/builtin.ts +46 -0
- package/src/daemon/control-types.ts +65 -0
- package/src/daemon/runtime.ts +51 -0
- package/src/defaults/gjc/skills/ultragoal/SKILL.md +16 -0
- package/src/edit/modes/replace.ts +1 -1
- package/src/extensibility/extensions/runner.ts +4 -0
- package/src/extensibility/extensions/types.ts +8 -0
- package/src/gjc-runtime/deep-interview-recorder.ts +12 -4
- package/src/gjc-runtime/launch-tmux.ts +10 -2
- package/src/gjc-runtime/state-runtime.ts +18 -4
- package/src/gjc-runtime/state-writer.ts +8 -8
- package/src/gjc-runtime/tmux-common.ts +8 -0
- package/src/gjc-runtime/tmux-sessions.ts +8 -1
- package/src/gjc-runtime/ultragoal-guard.ts +57 -2
- package/src/gjc-runtime/ultragoal-runtime.ts +105 -19
- package/src/gjc-runtime/workflow-manifest.generated.json +27 -2
- package/src/gjc-runtime/workflow-manifest.ts +11 -1
- package/src/goals/tools/goal-tool.ts +11 -2
- package/src/hashline/hash.ts +1 -1
- package/src/internal-urls/docs-index.generated.ts +9 -7
- package/src/main.ts +30 -0
- package/src/modes/acp/acp-event-mapper.ts +1 -0
- package/src/modes/components/hook-editor.ts +7 -2
- package/src/modes/components/oauth-selector.ts +19 -0
- package/src/modes/controllers/event-controller.ts +20 -0
- package/src/modes/controllers/selector-controller.ts +80 -17
- package/src/modes/interactive-mode.ts +6 -2
- package/src/modes/runtime-init.ts +1 -0
- package/src/modes/shared/agent-wire/event-contract.ts +1 -0
- package/src/modes/shared/agent-wire/event-envelope.ts +1 -0
- package/src/modes/shared/agent-wire/event-observation.ts +16 -0
- package/src/modes/shared/agent-wire/unattended-session.ts +22 -0
- package/src/modes/types.ts +7 -1
- package/src/modes/utils/ui-helpers.ts +23 -0
- package/src/notifications/config-commands.ts +50 -0
- package/src/notifications/config.ts +107 -0
- package/src/notifications/helpers.ts +135 -0
- package/src/notifications/html-format.ts +389 -0
- package/src/notifications/index.ts +700 -0
- package/src/notifications/rate-limit-pool.ts +179 -0
- package/src/notifications/telegram-cli.ts +194 -0
- package/src/notifications/telegram-daemon-cli.ts +74 -0
- package/src/notifications/telegram-daemon-control.ts +370 -0
- package/src/notifications/telegram-daemon.ts +1370 -0
- package/src/notifications/telegram-reference.ts +335 -0
- package/src/notifications/threaded-inbound.ts +80 -0
- package/src/notifications/threaded-render.ts +155 -0
- package/src/notifications/topic-registry.ts +133 -0
- package/src/rlm/index.ts +19 -0
- package/src/sdk.ts +16 -0
- package/src/session/agent-session.ts +113 -3
- package/src/session/auth-storage.ts +3 -0
- package/src/session/session-dump-format.ts +43 -2
- package/src/session/session-manager.ts +39 -5
- package/src/setup/credential-auto-import.ts +258 -0
- package/src/setup/credential-import.ts +17 -0
- package/src/setup/hermes/templates/operator-instructions.v1.md +10 -0
- package/src/setup/host-plugin-setup.ts +142 -0
- package/src/slash-commands/builtin-registry.ts +4 -1
- package/src/task/executor.ts +5 -1
- package/src/tools/ask-answer-registry.ts +25 -0
- package/src/tools/ask.ts +77 -6
- package/src/tools/image-gen.ts +5 -8
- package/src/tools/index.ts +19 -0
- package/src/tools/inspect-image.ts +16 -11
- package/src/tools/subagent-render.ts +7 -0
- package/src/tools/subagent.ts +38 -7
|
@@ -0,0 +1,335 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Telegram **reference** client for the notifications SDK.
|
|
3
|
+
*
|
|
4
|
+
* This is an example/template, NOT an upstream-owned integration: it implements
|
|
5
|
+
* the documented WS protocol (see `docs/notifications-sdk.md`) so you can copy it
|
|
6
|
+
* to build Discord/Slack/etc. clients with zero upstream changes. The Bot API
|
|
7
|
+
* transport shape is salvaged from the removed `telegram-remote` package.
|
|
8
|
+
*
|
|
9
|
+
* Flow: read the endpoint discovery file -> connect to the session WS -> render
|
|
10
|
+
* `action_needed` to a Telegram chat (inline keyboard for options) -> map button
|
|
11
|
+
* taps / text replies to `reply` frames -> reflect `action_resolved` /
|
|
12
|
+
* `reply_rejected`.
|
|
13
|
+
*
|
|
14
|
+
* Dependency-free: uses global `fetch` and `WebSocket` (Bun/Node 22+).
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import * as fs from "node:fs";
|
|
18
|
+
import { bold, buildButtonGrid, escapeHtml, TELEGRAM_PARSE_MODE, truncateTelegramHtml } from "./html-format";
|
|
19
|
+
import { renderThreadedFrame } from "./threaded-render";
|
|
20
|
+
|
|
21
|
+
/** One inline-keyboard button. */
|
|
22
|
+
export interface InlineButton {
|
|
23
|
+
text: string;
|
|
24
|
+
callback_data: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** A rendered Telegram message for an `action_needed`. */
|
|
28
|
+
export interface RenderedMessage {
|
|
29
|
+
text: string;
|
|
30
|
+
inline_keyboard?: InlineButton[][];
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Encode `actionId` + option `index` into Telegram callback_data (<=64 bytes). */
|
|
34
|
+
export function encodeCallbackData(actionId: string, index: number): string {
|
|
35
|
+
return `r:${index}:${actionId}`.slice(0, 64);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Decode callback_data produced by {@link encodeCallbackData}. */
|
|
39
|
+
export function decodeCallbackData(data: string): { id: string; index: number } | null {
|
|
40
|
+
const m = /^r:(\d+):(.+)$/.exec(data);
|
|
41
|
+
if (!m) return null;
|
|
42
|
+
return { index: Number(m[1]), id: m[2]! };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface CallbackRoute {
|
|
46
|
+
sessionId: string;
|
|
47
|
+
actionId: string;
|
|
48
|
+
answer: number | string;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export interface SerializedAliasTable {
|
|
52
|
+
version: 1;
|
|
53
|
+
next: number;
|
|
54
|
+
routes: Record<string, CallbackRoute>;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export interface AliasTable {
|
|
58
|
+
put(route: CallbackRoute): string;
|
|
59
|
+
get(alias: string): CallbackRoute | undefined;
|
|
60
|
+
delete(alias: string): boolean;
|
|
61
|
+
serialize(): SerializedAliasTable;
|
|
62
|
+
load(json: unknown): void;
|
|
63
|
+
entries(): Array<[string, CallbackRoute]>;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function isCallbackRoute(value: unknown): value is CallbackRoute {
|
|
67
|
+
if (!value || typeof value !== "object") return false;
|
|
68
|
+
const route = value as Partial<CallbackRoute>;
|
|
69
|
+
return (
|
|
70
|
+
typeof route.sessionId === "string" &&
|
|
71
|
+
typeof route.actionId === "string" &&
|
|
72
|
+
(typeof route.answer === "string" || typeof route.answer === "number")
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Create a compact, durable callback alias table. Serialized data contains routing ids only. */
|
|
77
|
+
export function createAliasTable(): AliasTable {
|
|
78
|
+
let next = 1;
|
|
79
|
+
const routes = new Map<string, CallbackRoute>();
|
|
80
|
+
return {
|
|
81
|
+
put(route) {
|
|
82
|
+
let alias: string;
|
|
83
|
+
do {
|
|
84
|
+
alias = `a${(next++).toString(36)}`;
|
|
85
|
+
} while (routes.has(alias));
|
|
86
|
+
if (Buffer.byteLength(alias, "utf8") > 64) throw new Error("callback alias exceeded Telegram limit");
|
|
87
|
+
routes.set(alias, { ...route });
|
|
88
|
+
return alias;
|
|
89
|
+
},
|
|
90
|
+
get(alias) {
|
|
91
|
+
const route = routes.get(alias);
|
|
92
|
+
return route ? { ...route } : undefined;
|
|
93
|
+
},
|
|
94
|
+
delete(alias) {
|
|
95
|
+
return routes.delete(alias);
|
|
96
|
+
},
|
|
97
|
+
serialize() {
|
|
98
|
+
return { version: 1, next, routes: Object.fromEntries(routes.entries()) };
|
|
99
|
+
},
|
|
100
|
+
load(json) {
|
|
101
|
+
routes.clear();
|
|
102
|
+
const data = typeof json === "string" ? JSON.parse(json) : json;
|
|
103
|
+
if (!data || typeof data !== "object") return;
|
|
104
|
+
const obj = data as { next?: unknown; routes?: unknown };
|
|
105
|
+
if (typeof obj.next === "number" && Number.isFinite(obj.next) && obj.next > 0) next = Math.floor(obj.next);
|
|
106
|
+
if (!obj.routes || typeof obj.routes !== "object" || Array.isArray(obj.routes)) return;
|
|
107
|
+
for (const [alias, route] of Object.entries(obj.routes)) {
|
|
108
|
+
if (Buffer.byteLength(alias, "utf8") <= 64 && isCallbackRoute(route)) routes.set(alias, { ...route });
|
|
109
|
+
}
|
|
110
|
+
},
|
|
111
|
+
entries() {
|
|
112
|
+
return Array.from(routes.entries()).map(([alias, route]) => [alias, { ...route }]);
|
|
113
|
+
},
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** Render an `action_needed` payload into a Telegram message. */
|
|
118
|
+
export function buildActionMessage(action: {
|
|
119
|
+
kind: "ask" | "idle";
|
|
120
|
+
id: string;
|
|
121
|
+
question?: string;
|
|
122
|
+
options?: string[];
|
|
123
|
+
summary?: string;
|
|
124
|
+
}): RenderedMessage {
|
|
125
|
+
if (action.kind === "idle") {
|
|
126
|
+
const text = action.summary ? `🟢 Agent idle\n${escapeHtml(action.summary)}` : "🟢 Agent idle";
|
|
127
|
+
return { text: truncateTelegramHtml(text) };
|
|
128
|
+
}
|
|
129
|
+
const text = `❓ ${bold(action.question ?? "Question")}`;
|
|
130
|
+
const options = action.options ?? [];
|
|
131
|
+
if (options.length === 0) return { text: truncateTelegramHtml(`${text}\n\n(reply with text)`) };
|
|
132
|
+
const inline_keyboard = buildButtonGrid(options, i => encodeCallbackData(action.id, i));
|
|
133
|
+
return { text: truncateTelegramHtml(text), inline_keyboard };
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** A protocol `reply` frame the client should send to the server. */
|
|
137
|
+
export interface ReplyFrame {
|
|
138
|
+
type: "reply";
|
|
139
|
+
id: string;
|
|
140
|
+
answer: number | string;
|
|
141
|
+
token: string;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Map a Telegram update into a reply frame, given the most recent pending ask id
|
|
146
|
+
* (for free-text replies). Returns `null` when the update is not actionable.
|
|
147
|
+
*/
|
|
148
|
+
export function telegramUpdateToReply(
|
|
149
|
+
update: unknown,
|
|
150
|
+
token: string,
|
|
151
|
+
latestPendingAskId: string | undefined,
|
|
152
|
+
): ReplyFrame | null {
|
|
153
|
+
const u = update as {
|
|
154
|
+
callback_query?: { data?: string };
|
|
155
|
+
message?: { text?: string };
|
|
156
|
+
};
|
|
157
|
+
if (u.callback_query?.data) {
|
|
158
|
+
const decoded = decodeCallbackData(u.callback_query.data);
|
|
159
|
+
if (decoded) return { type: "reply", id: decoded.id, answer: decoded.index, token };
|
|
160
|
+
}
|
|
161
|
+
if (u.message?.text && latestPendingAskId) {
|
|
162
|
+
return { type: "reply", id: latestPendingAskId, answer: u.message.text, token };
|
|
163
|
+
}
|
|
164
|
+
return null;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
export type RouteDecision =
|
|
168
|
+
| ({ kind: "reply" } & CallbackRoute)
|
|
169
|
+
| { kind: "stale"; reason: string }
|
|
170
|
+
| { kind: "ignore" };
|
|
171
|
+
|
|
172
|
+
export interface PendingAsk {
|
|
173
|
+
sessionId: string;
|
|
174
|
+
actionId: string;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
export interface RouteInboundContext {
|
|
178
|
+
aliasTable: Pick<AliasTable, "get">;
|
|
179
|
+
messageRoutes: Map<string | number, CallbackRoute | Omit<CallbackRoute, "answer">>;
|
|
180
|
+
pendingBySession: (sessionId?: string) => PendingAsk[];
|
|
181
|
+
pairedChatId: string;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
type TelegramUpdateShape = {
|
|
185
|
+
callback_query?: {
|
|
186
|
+
id?: unknown;
|
|
187
|
+
data?: unknown;
|
|
188
|
+
message?: { chat?: { id?: unknown }; message_id?: unknown };
|
|
189
|
+
};
|
|
190
|
+
message?: {
|
|
191
|
+
text?: unknown;
|
|
192
|
+
chat?: { id?: unknown };
|
|
193
|
+
message_id?: unknown;
|
|
194
|
+
reply_to_message?: { message_id?: unknown };
|
|
195
|
+
};
|
|
196
|
+
};
|
|
197
|
+
|
|
198
|
+
function updateChatId(update: TelegramUpdateShape): string | undefined {
|
|
199
|
+
const id = update.message?.chat?.id ?? update.callback_query?.message?.chat?.id;
|
|
200
|
+
return id === undefined || id === null ? undefined : String(id);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function routeWithAnswer(route: CallbackRoute | Omit<CallbackRoute, "answer">, answer: number | string): CallbackRoute {
|
|
204
|
+
return { sessionId: route.sessionId, actionId: route.actionId, answer };
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/** Route a Telegram update to a session/action without I/O. Fail closed under ambiguity. */
|
|
208
|
+
export function routeInboundUpdate(update: unknown, ctx: RouteInboundContext): RouteDecision {
|
|
209
|
+
const u = update as TelegramUpdateShape;
|
|
210
|
+
if (updateChatId(u) !== String(ctx.pairedChatId)) return { kind: "ignore" };
|
|
211
|
+
|
|
212
|
+
const callbackData = u.callback_query?.data;
|
|
213
|
+
if (typeof callbackData === "string") {
|
|
214
|
+
const route = ctx.aliasTable.get(callbackData);
|
|
215
|
+
return route ? { kind: "reply", ...route } : { kind: "stale", reason: "unknown_alias" };
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
const text = typeof u.message?.text === "string" ? u.message.text : undefined;
|
|
219
|
+
const replyTo = u.message?.reply_to_message?.message_id;
|
|
220
|
+
if (replyTo !== undefined && text) {
|
|
221
|
+
const route = ctx.messageRoutes.get(String(replyTo)) ?? ctx.messageRoutes.get(Number(replyTo));
|
|
222
|
+
if (!route) return { kind: "stale", reason: "unknown_reply_message" };
|
|
223
|
+
return { kind: "reply", ...routeWithAnswer(route, text) };
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
if (text) {
|
|
227
|
+
const allPending = ctx.pendingBySession(undefined);
|
|
228
|
+
if (allPending.length === 1) {
|
|
229
|
+
const [pending] = allPending;
|
|
230
|
+
return { kind: "reply", sessionId: pending!.sessionId, actionId: pending!.actionId, answer: text };
|
|
231
|
+
}
|
|
232
|
+
if (allPending.length > 1) return { kind: "stale", reason: "ambiguous_plain_text" };
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
return { kind: "ignore" };
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/** Read `{url, token}` from an endpoint discovery file. */
|
|
239
|
+
export function readEndpoint(path: string): { url: string; token: string } {
|
|
240
|
+
const raw = JSON.parse(fs.readFileSync(path, "utf8")) as { url?: unknown; token?: unknown };
|
|
241
|
+
if (typeof raw.url !== "string" || typeof raw.token !== "string") {
|
|
242
|
+
throw new Error(`invalid endpoint file: ${path}`);
|
|
243
|
+
}
|
|
244
|
+
return { url: raw.url, token: raw.token };
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/** Options for {@link runTelegramReferenceClient}. */
|
|
248
|
+
export interface TelegramReferenceOptions {
|
|
249
|
+
botToken: string;
|
|
250
|
+
chatId: string;
|
|
251
|
+
endpointFile: string;
|
|
252
|
+
apiBase?: string;
|
|
253
|
+
fetchImpl?: typeof fetch;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* Run the reference bridge until the WebSocket closes. Sends `action_needed` to
|
|
258
|
+
* the chat and forwards taps/text as replies. This is a minimal example loop;
|
|
259
|
+
* production clients add reconnection, multi-chat routing, and persistence.
|
|
260
|
+
*/
|
|
261
|
+
export async function runTelegramReferenceClient(opts: TelegramReferenceOptions): Promise<void> {
|
|
262
|
+
const fetchImpl = opts.fetchImpl ?? fetch;
|
|
263
|
+
const apiBase = opts.apiBase ?? "https://api.telegram.org";
|
|
264
|
+
const api = `${apiBase}/bot${opts.botToken}`;
|
|
265
|
+
const { url, token } = readEndpoint(opts.endpointFile);
|
|
266
|
+
|
|
267
|
+
const ws = new WebSocket(`${url}/?token=${encodeURIComponent(token)}`);
|
|
268
|
+
let latestPendingAskId: string | undefined;
|
|
269
|
+
|
|
270
|
+
const send = (method: string, body: unknown): Promise<Response> =>
|
|
271
|
+
fetchImpl(`${api}/${method}`, {
|
|
272
|
+
method: "POST",
|
|
273
|
+
headers: { "content-type": "application/json" },
|
|
274
|
+
body: JSON.stringify(body),
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
ws.addEventListener("message", (ev: MessageEvent) => {
|
|
278
|
+
const msg = JSON.parse(String(ev.data)) as {
|
|
279
|
+
type: string;
|
|
280
|
+
kind?: "ask" | "idle";
|
|
281
|
+
id?: string;
|
|
282
|
+
question?: string;
|
|
283
|
+
options?: string[];
|
|
284
|
+
summary?: string;
|
|
285
|
+
reason?: string;
|
|
286
|
+
};
|
|
287
|
+
if (msg.type === "action_needed" && msg.id) {
|
|
288
|
+
if (msg.kind === "ask") latestPendingAskId = msg.id;
|
|
289
|
+
const rendered = buildActionMessage({
|
|
290
|
+
kind: msg.kind ?? "ask",
|
|
291
|
+
id: msg.id,
|
|
292
|
+
question: msg.question,
|
|
293
|
+
options: msg.options,
|
|
294
|
+
summary: msg.summary,
|
|
295
|
+
});
|
|
296
|
+
void send("sendMessage", {
|
|
297
|
+
chat_id: opts.chatId,
|
|
298
|
+
text: rendered.text,
|
|
299
|
+
parse_mode: TELEGRAM_PARSE_MODE,
|
|
300
|
+
...(rendered.inline_keyboard ? { reply_markup: { inline_keyboard: rendered.inline_keyboard } } : {}),
|
|
301
|
+
});
|
|
302
|
+
} else if (msg.type === "action_resolved" && msg.id === latestPendingAskId) {
|
|
303
|
+
latestPendingAskId = undefined;
|
|
304
|
+
} else {
|
|
305
|
+
// Threaded frames (identity/context/turn/config): render as plain messages
|
|
306
|
+
// in this flat example client. The bundled daemon renders them into the
|
|
307
|
+
// session's forum topic; this reference shows the minimal handling.
|
|
308
|
+
const threaded = renderThreadedFrame(msg as never);
|
|
309
|
+
if (threaded?.text) {
|
|
310
|
+
void send("sendMessage", { chat_id: opts.chatId, text: threaded.text, parse_mode: TELEGRAM_PARSE_MODE });
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
// Telegram long-poll loop.
|
|
316
|
+
let offset = 0;
|
|
317
|
+
let running = true;
|
|
318
|
+
ws.addEventListener("close", () => {
|
|
319
|
+
running = false;
|
|
320
|
+
});
|
|
321
|
+
|
|
322
|
+
while (running) {
|
|
323
|
+
const res = await send("getUpdates", { offset, timeout: 25, allowed_updates: ["message", "callback_query"] });
|
|
324
|
+
const body = (await res.json()) as { result?: Array<{ update_id: number } & Record<string, unknown>> };
|
|
325
|
+
for (const update of body.result ?? []) {
|
|
326
|
+
offset = update.update_id + 1;
|
|
327
|
+
const callbackId = (update as { callback_query?: { id?: unknown } }).callback_query?.id;
|
|
328
|
+
if (typeof callbackId === "string") {
|
|
329
|
+
void send("answerCallbackQuery", { callback_query_id: callbackId });
|
|
330
|
+
}
|
|
331
|
+
const reply = telegramUpdateToReply(update, token, latestPendingAskId);
|
|
332
|
+
if (reply && ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify(reply));
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Fail-closed routing for inbound Telegram updates in threaded session mode.
|
|
3
|
+
*
|
|
4
|
+
* In the threaded surface, a free-text reply inside a session's forum topic
|
|
5
|
+
* injects a new user turn into that session (steering it at any time). That is
|
|
6
|
+
* remote control of the agent, so every inbound path must fail closed:
|
|
7
|
+
*
|
|
8
|
+
* - the update must come from the single paired chat id;
|
|
9
|
+
* - it must carry a `message_thread_id` (topic) that maps to a KNOWN session;
|
|
10
|
+
* - its `update_id` must not have been seen before (idempotency / replay guard);
|
|
11
|
+
* - the text must be non-empty.
|
|
12
|
+
*
|
|
13
|
+
* Anything ambiguous or unmapped is ignored with a reason rather than guessed.
|
|
14
|
+
* This module is pure (the dedupe set and topic map are injected) so the
|
|
15
|
+
* security rules are exhaustively unit-testable without a live Bot API.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
/** Minimal shape of the inbound Telegram message we route on. */
|
|
19
|
+
export interface InboundUpdate {
|
|
20
|
+
update_id?: unknown;
|
|
21
|
+
message?: {
|
|
22
|
+
message_id?: unknown;
|
|
23
|
+
text?: unknown;
|
|
24
|
+
chat?: { id?: unknown };
|
|
25
|
+
message_thread_id?: unknown;
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Context for {@link decideThreadedInbound}. All lookups are injected. */
|
|
30
|
+
export interface ThreadedInboundCtx {
|
|
31
|
+
/** The single paired chat id (string-compared). */
|
|
32
|
+
pairedChatId: string;
|
|
33
|
+
/** Resolve a topic/thread id to its owning session id, or undefined. */
|
|
34
|
+
topicToSession: (threadId: string) => string | undefined;
|
|
35
|
+
/** Whether this `update_id` has already been processed. */
|
|
36
|
+
isDuplicate: (updateId: number) => boolean;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Outcome of routing an inbound update. */
|
|
40
|
+
export type ThreadedInboundDecision =
|
|
41
|
+
| { kind: "inject"; sessionId: string; text: string; updateId: number; threadId: string; messageId?: number }
|
|
42
|
+
| { kind: "duplicate"; updateId: number }
|
|
43
|
+
| { kind: "ignore"; reason: string };
|
|
44
|
+
|
|
45
|
+
function asString(value: unknown): string | undefined {
|
|
46
|
+
if (typeof value === "string") return value;
|
|
47
|
+
if (typeof value === "number") return String(value);
|
|
48
|
+
return undefined;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Decide whether an inbound update should inject a user turn. Fail-closed:
|
|
53
|
+
* returns `ignore` (with a reason) or `duplicate` for anything that is not an
|
|
54
|
+
* unambiguous, first-seen, paired-chat, known-topic text message.
|
|
55
|
+
*/
|
|
56
|
+
export function decideThreadedInbound(update: InboundUpdate, ctx: ThreadedInboundCtx): ThreadedInboundDecision {
|
|
57
|
+
const message = update.message;
|
|
58
|
+
if (!message) return { kind: "ignore", reason: "no_message" };
|
|
59
|
+
|
|
60
|
+
const chatId = asString(message.chat?.id);
|
|
61
|
+
if (chatId === undefined || chatId !== String(ctx.pairedChatId)) {
|
|
62
|
+
return { kind: "ignore", reason: "wrong_chat" };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const threadId = asString(message.message_thread_id);
|
|
66
|
+
if (threadId === undefined) return { kind: "ignore", reason: "no_topic" };
|
|
67
|
+
|
|
68
|
+
const sessionId = ctx.topicToSession(threadId);
|
|
69
|
+
if (sessionId === undefined) return { kind: "ignore", reason: "unknown_topic" };
|
|
70
|
+
|
|
71
|
+
if (typeof update.update_id !== "number") return { kind: "ignore", reason: "missing_update_id" };
|
|
72
|
+
const updateId = update.update_id;
|
|
73
|
+
if (ctx.isDuplicate(updateId)) return { kind: "duplicate", updateId };
|
|
74
|
+
|
|
75
|
+
const text = typeof message.text === "string" ? message.text.trim() : "";
|
|
76
|
+
if (!text) return { kind: "ignore", reason: "empty_text" };
|
|
77
|
+
|
|
78
|
+
const messageId = typeof message.message_id === "number" ? message.message_id : undefined;
|
|
79
|
+
return { kind: "inject", sessionId, text, updateId, threadId, messageId };
|
|
80
|
+
}
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure rendering of threaded-session frames into Telegram send specs.
|
|
3
|
+
*
|
|
4
|
+
* The daemon receives the additive `ServerMessage` frames (identity_header,
|
|
5
|
+
* context_update, turn_stream, image_attachment, config_update) over the session
|
|
6
|
+
* WS and must turn each into a Bot API call scoped to the session's forum topic
|
|
7
|
+
* (`message_thread_id`), throttled through the shared rate-limit pool. This
|
|
8
|
+
* module is the pure frame→send mapping (including the priority lane and live-
|
|
9
|
+
* edit coalesce key), so rendering is unit-testable without a live Bot API.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { truncate } from "./helpers";
|
|
13
|
+
import { bold, code, escapeHtml, finalizeTelegramHtml, italic, markdownToTelegramHtml, pre } from "./html-format";
|
|
14
|
+
import type { RateLimitLane } from "./rate-limit-pool";
|
|
15
|
+
|
|
16
|
+
/** A Telegram send derived from a threaded frame (topic id is applied by the daemon). */
|
|
17
|
+
export interface ThreadedSend {
|
|
18
|
+
method: "sendMessage" | "sendPhoto";
|
|
19
|
+
/** Rate-limit lane for prioritisation/fairness. */
|
|
20
|
+
lane: RateLimitLane;
|
|
21
|
+
/** Message text (sendMessage) or photo caption (sendPhoto). */
|
|
22
|
+
text?: string;
|
|
23
|
+
/** Base64 image bytes for sendPhoto. */
|
|
24
|
+
photoBase64?: string;
|
|
25
|
+
/** Image MIME type for sendPhoto. */
|
|
26
|
+
mime?: string;
|
|
27
|
+
/** Coalesce key for live edits (same key collapses to the latest). */
|
|
28
|
+
coalesceKey?: string;
|
|
29
|
+
/** True for the one-time identity header (the daemon pins it once). */
|
|
30
|
+
identity?: boolean;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
interface ThreadedFrame {
|
|
34
|
+
type?: unknown;
|
|
35
|
+
sessionId?: unknown;
|
|
36
|
+
// identity_header
|
|
37
|
+
repo?: unknown;
|
|
38
|
+
branch?: unknown;
|
|
39
|
+
machine?: unknown;
|
|
40
|
+
title?: unknown;
|
|
41
|
+
// context_update
|
|
42
|
+
lastMessage?: unknown;
|
|
43
|
+
task?: unknown;
|
|
44
|
+
goal?: unknown;
|
|
45
|
+
tokenUsage?: unknown;
|
|
46
|
+
model?: unknown;
|
|
47
|
+
diff?: unknown;
|
|
48
|
+
// turn_stream
|
|
49
|
+
phase?: unknown;
|
|
50
|
+
text?: unknown;
|
|
51
|
+
messageRef?: unknown;
|
|
52
|
+
// image_attachment
|
|
53
|
+
source?: unknown;
|
|
54
|
+
data?: unknown;
|
|
55
|
+
mime?: unknown;
|
|
56
|
+
caption?: unknown;
|
|
57
|
+
// config_update
|
|
58
|
+
verbosity?: unknown;
|
|
59
|
+
redact?: unknown;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function str(v: unknown): string | undefined {
|
|
63
|
+
return typeof v === "string" && v.length > 0 ? v : undefined;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Format the one-time identity header as pinned bullets. */
|
|
67
|
+
export function formatIdentityHeader(frame: {
|
|
68
|
+
repo?: unknown;
|
|
69
|
+
branch?: unknown;
|
|
70
|
+
machine?: unknown;
|
|
71
|
+
sessionId?: unknown;
|
|
72
|
+
title?: unknown;
|
|
73
|
+
}): string {
|
|
74
|
+
const title = str(frame.title) ?? "GJC session";
|
|
75
|
+
const bullets = [
|
|
76
|
+
`• repo: ${code(str(frame.repo) ?? "?")}`,
|
|
77
|
+
`• branch: ${code(str(frame.branch) ?? "?")}`,
|
|
78
|
+
`• machine: ${code(str(frame.machine) ?? "?")}`,
|
|
79
|
+
`• session: ${code(str(frame.sessionId) ?? "?")}`,
|
|
80
|
+
];
|
|
81
|
+
return `${bold(title)}\n${bullets.join("\n")}`;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Format a streamed context update into a compact block (omitting empty fields). */
|
|
85
|
+
export function formatContextUpdate(frame: ThreadedFrame): string | undefined {
|
|
86
|
+
const lines: string[] = [];
|
|
87
|
+
const last = str(frame.lastMessage);
|
|
88
|
+
if (last) lines.push(escapeHtml(truncate(last, 600)));
|
|
89
|
+
const task = str(frame.task);
|
|
90
|
+
if (task) lines.push(`${italic("task:")} ${escapeHtml(task)}`);
|
|
91
|
+
const goal = str(frame.goal);
|
|
92
|
+
if (goal) lines.push(`${italic("goal:")} ${escapeHtml(goal)}`);
|
|
93
|
+
const usage = str(frame.tokenUsage);
|
|
94
|
+
const model = str(frame.model);
|
|
95
|
+
if (usage || model) lines.push(`ctx: ${code([usage, model].filter(Boolean).join(" · "))}`);
|
|
96
|
+
const diff = str(frame.diff);
|
|
97
|
+
if (diff) lines.push(`diff:\n${pre(truncate(diff, 1200))}`);
|
|
98
|
+
return lines.length ? lines.join("\n") : undefined;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Map a threaded frame to a Telegram send spec, or `undefined` when there is
|
|
103
|
+
* nothing to send (e.g. an empty context update or an unknown frame type).
|
|
104
|
+
*/
|
|
105
|
+
export function renderThreadedFrame(frame: ThreadedFrame): ThreadedSend | undefined {
|
|
106
|
+
switch (frame.type) {
|
|
107
|
+
case "identity_header":
|
|
108
|
+
return {
|
|
109
|
+
method: "sendMessage",
|
|
110
|
+
lane: "finalized",
|
|
111
|
+
text: finalizeTelegramHtml(formatIdentityHeader(frame)),
|
|
112
|
+
identity: true,
|
|
113
|
+
};
|
|
114
|
+
case "context_update": {
|
|
115
|
+
const text = finalizeTelegramHtml(formatContextUpdate(frame));
|
|
116
|
+
return text
|
|
117
|
+
? { method: "sendMessage", lane: "live", text, coalesceKey: `ctx:${str(frame.sessionId) ?? ""}` }
|
|
118
|
+
: undefined;
|
|
119
|
+
}
|
|
120
|
+
case "turn_stream": {
|
|
121
|
+
const raw = str(frame.text);
|
|
122
|
+
if (!raw) return undefined;
|
|
123
|
+
const text = finalizeTelegramHtml(markdownToTelegramHtml(raw));
|
|
124
|
+
const finalized = frame.phase === "finalized";
|
|
125
|
+
return {
|
|
126
|
+
method: "sendMessage",
|
|
127
|
+
lane: finalized ? "finalized" : "live",
|
|
128
|
+
text,
|
|
129
|
+
coalesceKey: finalized ? undefined : `turn:${str(frame.messageRef) ?? str(frame.sessionId) ?? ""}`,
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
case "image_attachment": {
|
|
133
|
+
const data = str(frame.data);
|
|
134
|
+
if (!data) return undefined;
|
|
135
|
+
const caption = str(frame.caption);
|
|
136
|
+
return {
|
|
137
|
+
method: "sendPhoto",
|
|
138
|
+
lane: "finalized",
|
|
139
|
+
photoBase64: data,
|
|
140
|
+
mime: str(frame.mime),
|
|
141
|
+
text: finalizeTelegramHtml(caption === undefined ? undefined : escapeHtml(caption)),
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
case "config_update": {
|
|
145
|
+
const verbosity = str(frame.verbosity);
|
|
146
|
+
const redact = typeof frame.redact === "boolean" ? `redact ${frame.redact ? "on" : "off"}` : undefined;
|
|
147
|
+
const parts = [verbosity ? `verbosity ${verbosity}` : undefined, redact].filter(Boolean);
|
|
148
|
+
return parts.length
|
|
149
|
+
? { method: "sendMessage", lane: "idle", text: finalizeTelegramHtml(`⚙ ${escapeHtml(parts.join(", "))}`) }
|
|
150
|
+
: undefined;
|
|
151
|
+
}
|
|
152
|
+
default:
|
|
153
|
+
return undefined;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-session forum-topic registry for the threaded session surface.
|
|
3
|
+
*
|
|
4
|
+
* Each GJC session owns exactly one Telegram forum topic in the paired private
|
|
5
|
+
* DM. The topic is created once (via `createForumTopic`) and REUSED on resume,
|
|
6
|
+
* keyed by session id, so a resumed session streams back into its existing
|
|
7
|
+
* thread/history. The registry also tracks whether the one-time identity header
|
|
8
|
+
* has already been pinned, so it is sent exactly once per topic even across
|
|
9
|
+
* reconnects.
|
|
10
|
+
*
|
|
11
|
+
* State is a plain serialisable map persisted beside the daemon state files;
|
|
12
|
+
* topic creation is injected so this module is pure and unit-testable without a
|
|
13
|
+
* live Bot API.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
/** Persisted record for one session's topic. */
|
|
17
|
+
export interface TopicRecord {
|
|
18
|
+
/** Telegram forum topic id (message_thread_id). */
|
|
19
|
+
topicId: string;
|
|
20
|
+
/** Whether the one-time identity header has been sent/pinned. */
|
|
21
|
+
identitySent: boolean;
|
|
22
|
+
/** Creation timestamp (ms epoch). */
|
|
23
|
+
createdAt: number;
|
|
24
|
+
/** Last applied topic title (for rename detection). */
|
|
25
|
+
name?: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Serialisable shape persisted to disk. */
|
|
29
|
+
export interface TopicRegistryState {
|
|
30
|
+
/** sessionId -> record. */
|
|
31
|
+
topics: Record<string, TopicRecord>;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function emptyTopicRegistryState(): TopicRegistryState {
|
|
35
|
+
return { topics: {} };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* In-memory registry over a serialisable state. Topic creation is injected via
|
|
40
|
+
* `getOrCreateTopic`'s `create` callback (the daemon supplies a real
|
|
41
|
+
* `createForumTopic` call); reuse-on-resume is automatic when a record exists.
|
|
42
|
+
*/
|
|
43
|
+
export class TopicRegistry {
|
|
44
|
+
private readonly topics: Map<string, TopicRecord>;
|
|
45
|
+
/** Maps topicId -> sessionId for fast inbound routing. */
|
|
46
|
+
private readonly byTopic = new Map<string, string>();
|
|
47
|
+
/** In-flight create promises, keyed by session, to dedupe concurrent creates. */
|
|
48
|
+
private readonly inflight = new Map<string, Promise<TopicRecord>>();
|
|
49
|
+
|
|
50
|
+
constructor(state: TopicRegistryState = emptyTopicRegistryState()) {
|
|
51
|
+
this.topics = new Map(Object.entries(state.topics ?? {}));
|
|
52
|
+
for (const [sessionId, record] of this.topics) this.byTopic.set(record.topicId, sessionId);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Merge a serialized state into this registry, preserving all persisted fields. */
|
|
56
|
+
load(state: TopicRegistryState): void {
|
|
57
|
+
for (const [sessionId, record] of Object.entries(state.topics ?? {})) {
|
|
58
|
+
this.topics.set(sessionId, record);
|
|
59
|
+
this.byTopic.set(record.topicId, sessionId);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Resolve the owning session for a topic id (for fail-closed inbound routing). */
|
|
64
|
+
sessionForTopic(topicId: string): string | undefined {
|
|
65
|
+
return this.byTopic.get(topicId);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** The existing topic record for a session, if any. */
|
|
69
|
+
get(sessionId: string): TopicRecord | undefined {
|
|
70
|
+
return this.topics.get(sessionId);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Return the existing topic for `sessionId`, or create one via `create`
|
|
75
|
+
* (called only on first use). Reuse-on-resume: an existing record is
|
|
76
|
+
* returned without invoking `create`.
|
|
77
|
+
*/
|
|
78
|
+
async getOrCreateTopic(
|
|
79
|
+
sessionId: string,
|
|
80
|
+
create: () => Promise<string>,
|
|
81
|
+
now: () => number = Date.now,
|
|
82
|
+
): Promise<TopicRecord> {
|
|
83
|
+
const existing = this.topics.get(sessionId);
|
|
84
|
+
if (existing) return existing;
|
|
85
|
+
// Concurrency guard: many session frames (identity/idle/turn/ask) can race
|
|
86
|
+
// to first-use the same session. Without this, each call passes the
|
|
87
|
+
// `existing` check before `create()` resolves and creates a DUPLICATE
|
|
88
|
+
// forum topic. Share a single in-flight create per session id.
|
|
89
|
+
const pending = this.inflight.get(sessionId);
|
|
90
|
+
if (pending) return pending;
|
|
91
|
+
const promise = (async () => {
|
|
92
|
+
const topicId = await create();
|
|
93
|
+
const record: TopicRecord = { topicId, identitySent: false, createdAt: now() };
|
|
94
|
+
this.topics.set(sessionId, record);
|
|
95
|
+
this.byTopic.set(topicId, sessionId);
|
|
96
|
+
return record;
|
|
97
|
+
})();
|
|
98
|
+
this.inflight.set(sessionId, promise);
|
|
99
|
+
try {
|
|
100
|
+
return await promise;
|
|
101
|
+
} finally {
|
|
102
|
+
this.inflight.delete(sessionId);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Mark the identity header as sent for a session. Idempotent. */
|
|
107
|
+
markIdentitySent(sessionId: string): void {
|
|
108
|
+
const record = this.topics.get(sessionId);
|
|
109
|
+
if (record) record.identitySent = true;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** Whether the identity header still needs sending for this session. */
|
|
113
|
+
needsIdentity(sessionId: string): boolean {
|
|
114
|
+
const record = this.topics.get(sessionId);
|
|
115
|
+
return record ? !record.identitySent : true;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Record the topic's applied title. Returns `true` when it changed (so the
|
|
120
|
+
* caller should `editForumTopic`), `false` when already current or unknown.
|
|
121
|
+
*/
|
|
122
|
+
applyName(sessionId: string, name: string): boolean {
|
|
123
|
+
const record = this.topics.get(sessionId);
|
|
124
|
+
if (!record || record.name === name) return false;
|
|
125
|
+
record.name = name;
|
|
126
|
+
return true;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** Serialise for atomic persistence beside the daemon state. */
|
|
130
|
+
serialize(): TopicRegistryState {
|
|
131
|
+
return { topics: Object.fromEntries(this.topics) };
|
|
132
|
+
}
|
|
133
|
+
}
|