viber-channel 0.5.3 → 0.7.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/README.md +28 -0
- package/lib/agent_tools.ts +201 -0
- package/lib/auth.ts +50 -1
- package/lib/bridge_core.ts +1019 -0
- package/lib/bridge_spawn.ts +122 -0
- package/lib/bridge_tool_host.ts +191 -0
- package/lib/channel_session.ts +20 -17
- package/lib/connect.ts +10 -1
- package/lib/control_stream.ts +351 -0
- package/lib/conversation.ts +23 -9
- package/lib/dm_stream.ts +287 -0
- package/lib/fingerprint.ts +1 -1
- package/lib/heartbeat.ts +95 -0
- package/lib/instance.ts +164 -0
- package/lib/lockfile.ts +36 -0
- package/lib/messages.ts +32 -0
- package/lib/ollama_chat.ts +106 -0
- package/lib/parent_watchdog.ts +276 -0
- package/lib/peers.ts +113 -0
- package/lib/self_echo.ts +17 -0
- package/lib/supervisor.ts +303 -0
- package/lib/supervisor_config.ts +140 -0
- package/package.json +9 -2
- package/viber-channel.ts +376 -71
- package/viber-codex-bridge.ts +1261 -0
- package/viber-codex-supervisor.ts +96 -0
- package/viber-gemma-bridge.ts +169 -0
|
@@ -0,0 +1,351 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* control_stream — subscribe to an instance-scoped server→agent control stream
|
|
3
|
+
* (#280 step-01/03).
|
|
4
|
+
*
|
|
5
|
+
* The control stream (`GET /api/instances/<id>/events`, authenticated by the
|
|
6
|
+
* instance_token) is how the server pushes commands to an agent BEFORE it is in
|
|
7
|
+
* any conversation. Today it carries:
|
|
8
|
+
* - `connected` — open marker
|
|
9
|
+
* - `ping` — keepalive
|
|
10
|
+
* - `stop` — the instance was revoked; the agent should exit
|
|
11
|
+
* - `join` — admit this agent to a conversation; the payload IS a
|
|
12
|
+
* ConversationMintResponse (conversation_id, conversation_token,
|
|
13
|
+
* ws_url, expires_at), so it feeds the conversation SSE loop
|
|
14
|
+
* directly with no copy-pasted ids or tokens (the step-03 payoff).
|
|
15
|
+
*
|
|
16
|
+
* This module is intentionally side-effect-free and runtime-agnostic (no Codex /
|
|
17
|
+
* Claude specifics) so both bridges can consume it and it can be unit-tested with
|
|
18
|
+
* a mock fetch.
|
|
19
|
+
*/
|
|
20
|
+
import { cfAccessHeaders } from "./cfAccess.js";
|
|
21
|
+
import type { ConversationMintResponse } from "./conversation.js";
|
|
22
|
+
|
|
23
|
+
/** One parsed Server-Sent Event from the control stream. */
|
|
24
|
+
export interface ControlEvent {
|
|
25
|
+
event: string;
|
|
26
|
+
data: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Build the instance control-stream SSE URL. */
|
|
30
|
+
export function buildControlStreamUrl(baseUrl: string, instanceId: string): string {
|
|
31
|
+
return `${baseUrl}/api/instances/${instanceId}/events`;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* POST /api/instances/:id/heartbeat — instance control-stream liveness (#311).
|
|
36
|
+
*
|
|
37
|
+
* The agent beats on a fixed interval so the server's #311 sweeper never purges a
|
|
38
|
+
* live-but-idle agent: a server→client SSE keepalive can't prove this process is
|
|
39
|
+
* still alive behind the Cloudflare tunnel, but this beat can. Best-effort —
|
|
40
|
+
* `true` on 2xx, `false` on any non-2xx / network error. Bounded by a per-POST
|
|
41
|
+
* timeout so a hung tunnel socket can't stack beats (mirrors #298).
|
|
42
|
+
*/
|
|
43
|
+
export async function sendInstanceHeartbeat(
|
|
44
|
+
baseUrl: string,
|
|
45
|
+
instanceId: string,
|
|
46
|
+
instanceToken: string,
|
|
47
|
+
timeoutMs = 20_000,
|
|
48
|
+
fetchImpl: typeof fetch = fetch,
|
|
49
|
+
): Promise<boolean> {
|
|
50
|
+
try {
|
|
51
|
+
const resp = await fetchImpl(`${baseUrl}/api/instances/${instanceId}/heartbeat`, {
|
|
52
|
+
method: "POST",
|
|
53
|
+
headers: {
|
|
54
|
+
Authorization: `Bearer ${instanceToken}`,
|
|
55
|
+
"Content-Type": "application/json",
|
|
56
|
+
...cfAccessHeaders(),
|
|
57
|
+
},
|
|
58
|
+
body: "{}",
|
|
59
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
60
|
+
});
|
|
61
|
+
return resp.ok;
|
|
62
|
+
} catch {
|
|
63
|
+
return false;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export interface ControlStreamOptions {
|
|
68
|
+
baseUrl: string;
|
|
69
|
+
instanceId: string;
|
|
70
|
+
instanceToken: string;
|
|
71
|
+
signal?: AbortSignal;
|
|
72
|
+
/** Injectable for tests; defaults to global fetch. */
|
|
73
|
+
fetchImpl?: typeof fetch;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Thrown when the control stream is rejected with 401 — the instance_token is
|
|
78
|
+
* revoked or invalid, so the agent must re-register (a fresh instance_id).
|
|
79
|
+
*/
|
|
80
|
+
export class ControlStreamAuthError extends Error {
|
|
81
|
+
constructor() {
|
|
82
|
+
super("control stream unauthorized (instance token revoked or invalid)");
|
|
83
|
+
this.name = "ControlStreamAuthError";
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Async generator that connects to the instance control stream and yields each
|
|
89
|
+
* parsed `{ event, data }`. One connection attempt — the caller decides whether
|
|
90
|
+
* to reconnect (the server keeps the stream open with `ping` keepalives). Throws
|
|
91
|
+
* `ControlStreamAuthError` on 401; the AbortSignal ends it cleanly.
|
|
92
|
+
*/
|
|
93
|
+
export async function* subscribeControlStream(
|
|
94
|
+
opts: ControlStreamOptions,
|
|
95
|
+
): AsyncGenerator<ControlEvent> {
|
|
96
|
+
const doFetch = opts.fetchImpl ?? fetch;
|
|
97
|
+
const url = buildControlStreamUrl(opts.baseUrl, opts.instanceId);
|
|
98
|
+
|
|
99
|
+
const resp = await doFetch(url, {
|
|
100
|
+
headers: {
|
|
101
|
+
Authorization: `Bearer ${opts.instanceToken}`,
|
|
102
|
+
Accept: "text/event-stream",
|
|
103
|
+
"Cache-Control": "no-cache",
|
|
104
|
+
...cfAccessHeaders(),
|
|
105
|
+
},
|
|
106
|
+
signal: opts.signal,
|
|
107
|
+
});
|
|
108
|
+
if (resp.status === 401) throw new ControlStreamAuthError();
|
|
109
|
+
if (!resp.ok || !resp.body) {
|
|
110
|
+
throw new Error(`control stream connection failed: HTTP ${resp.status}`);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const reader = resp.body.getReader();
|
|
114
|
+
const decoder = new TextDecoder();
|
|
115
|
+
let buffer = "";
|
|
116
|
+
|
|
117
|
+
try {
|
|
118
|
+
while (true) {
|
|
119
|
+
const { done, value } = await reader.read();
|
|
120
|
+
if (done) break;
|
|
121
|
+
// Normalise CRLF → LF: the SSE spec allows \r\n line/record separators and
|
|
122
|
+
// some servers (incl. Cloudflare) emit them; without this the `\n\n` split
|
|
123
|
+
// never matches and a trailing `\r` corrupts every field value.
|
|
124
|
+
buffer += decoder.decode(value, { stream: true }).replace(/\r\n/g, "\n").replace(/\r/g, "\n");
|
|
125
|
+
|
|
126
|
+
while (true) {
|
|
127
|
+
const eventEnd = buffer.indexOf("\n\n");
|
|
128
|
+
if (eventEnd === -1) break;
|
|
129
|
+
const block = buffer.slice(0, eventEnd);
|
|
130
|
+
buffer = buffer.slice(eventEnd + 2);
|
|
131
|
+
|
|
132
|
+
let event = "";
|
|
133
|
+
let data = "";
|
|
134
|
+
for (const line of block.split("\n")) {
|
|
135
|
+
if (line.startsWith(":")) continue; // comment line (keepalive)
|
|
136
|
+
if (line.startsWith("event: ")) event = line.slice(7);
|
|
137
|
+
else if (line.startsWith("data: ")) data = line.slice(6);
|
|
138
|
+
}
|
|
139
|
+
if (event) yield { event, data };
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
} finally {
|
|
143
|
+
// Release the reader (and cancel the body) so an early `break`/`return` by
|
|
144
|
+
// the consumer — e.g. waitForJoin resolving on the first join — or an abort
|
|
145
|
+
// doesn't leak the underlying connection until GC.
|
|
146
|
+
try {
|
|
147
|
+
await reader.cancel();
|
|
148
|
+
} catch {
|
|
149
|
+
/* already closed / aborted */
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** Raised when the control stream is signalled `stop` (instance revoked). */
|
|
155
|
+
export class ControlStreamStopped extends Error {
|
|
156
|
+
constructor() {
|
|
157
|
+
super("control stream received stop (instance revoked)");
|
|
158
|
+
this.name = "ControlStreamStopped";
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Parse a `join` event's data into a validated `ConversationMintResponse`, or
|
|
164
|
+
* null if it is malformed / missing fields (the caller skips it and waits for
|
|
165
|
+
* the next). Shared by waitForJoin and runPersistentControlStream.
|
|
166
|
+
*/
|
|
167
|
+
function parseJoinPayload(
|
|
168
|
+
data: string,
|
|
169
|
+
log: (msg: string) => void,
|
|
170
|
+
): ConversationMintResponse | null {
|
|
171
|
+
let payload: Partial<ConversationMintResponse>;
|
|
172
|
+
try {
|
|
173
|
+
payload = JSON.parse(data) as Partial<ConversationMintResponse>;
|
|
174
|
+
} catch {
|
|
175
|
+
log("[control-stream] ignored malformed join payload\n");
|
|
176
|
+
return null;
|
|
177
|
+
}
|
|
178
|
+
if (
|
|
179
|
+
typeof payload.conversation_id === "string" &&
|
|
180
|
+
typeof payload.conversation_token === "string" &&
|
|
181
|
+
typeof payload.ws_url === "string" &&
|
|
182
|
+
typeof payload.expires_at === "number"
|
|
183
|
+
) {
|
|
184
|
+
return payload as ConversationMintResponse;
|
|
185
|
+
}
|
|
186
|
+
log("[control-stream] ignored join payload with missing fields\n");
|
|
187
|
+
return null;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Consume the control stream until the first `join` event and return its payload
|
|
192
|
+
* as a `ConversationMintResponse` — ready to feed the conversation SSE loop. A
|
|
193
|
+
* `stop` event before any join throws `ControlStreamStopped`. `connected`/`ping`
|
|
194
|
+
* are ignored (keepalive). Malformed `join` data is skipped (waits for the next).
|
|
195
|
+
*
|
|
196
|
+
* NOTE: this CLOSES the stream on the first join (the generator returns). For the
|
|
197
|
+
* control-plane DM model — where presence must survive after joining — use
|
|
198
|
+
* runPersistentControlStream instead, which keeps the stream open for life.
|
|
199
|
+
*/
|
|
200
|
+
export async function waitForJoin(
|
|
201
|
+
opts: ControlStreamOptions,
|
|
202
|
+
log: (msg: string) => void = () => {},
|
|
203
|
+
): Promise<ConversationMintResponse> {
|
|
204
|
+
for await (const ev of subscribeControlStream(opts)) {
|
|
205
|
+
if (ev.event === "stop") throw new ControlStreamStopped();
|
|
206
|
+
if (ev.event !== "join") continue; // connected / ping / unknown → ignore
|
|
207
|
+
const minted = parseJoinPayload(ev.data, log);
|
|
208
|
+
if (minted) return minted;
|
|
209
|
+
}
|
|
210
|
+
// Stream ended without a join — the caller reconnects.
|
|
211
|
+
throw new Error("control stream ended before a join event");
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/** Resolve after `ms`, or early when `signal` aborts. */
|
|
215
|
+
function abortableDelay(ms: number, signal?: AbortSignal): Promise<void> {
|
|
216
|
+
return new Promise((resolve) => {
|
|
217
|
+
if (signal?.aborted) return resolve();
|
|
218
|
+
const onAbort = () => {
|
|
219
|
+
clearTimeout(timer);
|
|
220
|
+
resolve();
|
|
221
|
+
};
|
|
222
|
+
const timer = setTimeout(() => {
|
|
223
|
+
signal?.removeEventListener("abort", onAbort);
|
|
224
|
+
resolve();
|
|
225
|
+
}, ms);
|
|
226
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
227
|
+
});
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
export interface PersistentControlStreamHandlers {
|
|
231
|
+
/** Called for EACH pushed join (the conversation to admit this agent into). */
|
|
232
|
+
onJoin?: (minted: ConversationMintResponse) => void;
|
|
233
|
+
/**
|
|
234
|
+
* Called once when the stream terminates for a NON-transient reason:
|
|
235
|
+
* "stopped" — a `stop` event arrived (the instance was revoked)
|
|
236
|
+
* "unauthorized" — the control stream returned 401 (token revoked/invalid)
|
|
237
|
+
* The agent should shut down. NOT called on transient drops (those reconnect).
|
|
238
|
+
*/
|
|
239
|
+
onStop?: (reason: "stopped" | "unauthorized") => void;
|
|
240
|
+
/**
|
|
241
|
+
* Called on a `beat_now` event (#311): the server is actively probing this
|
|
242
|
+
* agent's liveness — respond with an IMMEDIATE instance heartbeat so a live
|
|
243
|
+
* agent is confirmed within seconds (and a dead one, which never gets here, is
|
|
244
|
+
* force-offlined at the probe deadline).
|
|
245
|
+
*/
|
|
246
|
+
onBeatNow?: () => void;
|
|
247
|
+
log?: (msg: string) => void;
|
|
248
|
+
/** Backoff before reconnecting after a drop / clean end. Default 2000ms. */
|
|
249
|
+
reconnectDelayMs?: number;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
export interface PersistentControlStream {
|
|
253
|
+
/**
|
|
254
|
+
* Resolves with the FIRST pushed join — the caller starts the conversation
|
|
255
|
+
* from it. Rejects with ControlStreamStopped / ControlStreamAuthError if the
|
|
256
|
+
* stream ends (revoke / 401) before any join, or a generic Error on abort.
|
|
257
|
+
*/
|
|
258
|
+
firstJoin: Promise<ConversationMintResponse>;
|
|
259
|
+
/** Resolves when the run loop exits (abort, stop, or auth error). */
|
|
260
|
+
done: Promise<void>;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* Run the instance control stream for the agent's WHOLE lifetime (hub-and-spoke:
|
|
265
|
+
* ONE persistent server→agent link). Unlike waitForJoin — which returns on the
|
|
266
|
+
* first join and CLOSES the stream — this keeps the stream open AFTER the first
|
|
267
|
+
* join so server-side presence (#280 step-02) keeps seeing the agent online and
|
|
268
|
+
* re-invitable (step-20, Codex P1-3). Reconnects with backoff on transient drops;
|
|
269
|
+
* a `stop` (revoke) or 401 ends the loop via onStop. The AbortSignal ends it.
|
|
270
|
+
*/
|
|
271
|
+
export function runPersistentControlStream(
|
|
272
|
+
opts: ControlStreamOptions,
|
|
273
|
+
handlers: PersistentControlStreamHandlers = {},
|
|
274
|
+
): PersistentControlStream {
|
|
275
|
+
const log = handlers.log ?? (() => {});
|
|
276
|
+
const delayMs = handlers.reconnectDelayMs ?? 2000;
|
|
277
|
+
const signal = opts.signal;
|
|
278
|
+
|
|
279
|
+
let firstSettled = false;
|
|
280
|
+
let resolveFirst!: (m: ConversationMintResponse) => void;
|
|
281
|
+
let rejectFirst!: (err: unknown) => void;
|
|
282
|
+
const firstJoin = new Promise<ConversationMintResponse>((res, rej) => {
|
|
283
|
+
resolveFirst = res;
|
|
284
|
+
rejectFirst = rej;
|
|
285
|
+
});
|
|
286
|
+
// Mark settled synchronously so the rejection on abort never fires after a
|
|
287
|
+
// resolve (and vice-versa) — and so an unobserved firstJoin still rejects once.
|
|
288
|
+
const settleFirstResolve = (m: ConversationMintResponse) => {
|
|
289
|
+
if (firstSettled) return;
|
|
290
|
+
firstSettled = true;
|
|
291
|
+
resolveFirst(m);
|
|
292
|
+
};
|
|
293
|
+
const settleFirstReject = (err: unknown) => {
|
|
294
|
+
if (firstSettled) return;
|
|
295
|
+
firstSettled = true;
|
|
296
|
+
rejectFirst(err);
|
|
297
|
+
};
|
|
298
|
+
|
|
299
|
+
const done = (async () => {
|
|
300
|
+
try {
|
|
301
|
+
while (true) {
|
|
302
|
+
if (signal?.aborted) {
|
|
303
|
+
settleFirstReject(new Error("control stream aborted before join"));
|
|
304
|
+
return;
|
|
305
|
+
}
|
|
306
|
+
try {
|
|
307
|
+
for await (const ev of subscribeControlStream(opts)) {
|
|
308
|
+
if (ev.event === "stop") {
|
|
309
|
+
settleFirstReject(new ControlStreamStopped());
|
|
310
|
+
handlers.onStop?.("stopped");
|
|
311
|
+
return;
|
|
312
|
+
}
|
|
313
|
+
if (ev.event === "beat_now") {
|
|
314
|
+
handlers.onBeatNow?.();
|
|
315
|
+
continue;
|
|
316
|
+
}
|
|
317
|
+
if (ev.event !== "join") continue; // connected / ping / unknown
|
|
318
|
+
const minted = parseJoinPayload(ev.data, log);
|
|
319
|
+
if (!minted) continue;
|
|
320
|
+
handlers.onJoin?.(minted);
|
|
321
|
+
settleFirstResolve(minted);
|
|
322
|
+
}
|
|
323
|
+
// Clean end (server closed the SSE) → reconnect after backoff.
|
|
324
|
+
} catch (err) {
|
|
325
|
+
if (err instanceof ControlStreamAuthError) {
|
|
326
|
+
settleFirstReject(err);
|
|
327
|
+
handlers.onStop?.("unauthorized");
|
|
328
|
+
return;
|
|
329
|
+
}
|
|
330
|
+
if (signal?.aborted) {
|
|
331
|
+
settleFirstReject(new Error("control stream aborted before join"));
|
|
332
|
+
return;
|
|
333
|
+
}
|
|
334
|
+
log(
|
|
335
|
+
`[control-stream] ${err instanceof Error ? err.message : String(err)}, reconnecting...\n`,
|
|
336
|
+
);
|
|
337
|
+
}
|
|
338
|
+
if (signal?.aborted) {
|
|
339
|
+
settleFirstReject(new Error("control stream aborted before join"));
|
|
340
|
+
return;
|
|
341
|
+
}
|
|
342
|
+
await abortableDelay(delayMs, signal);
|
|
343
|
+
}
|
|
344
|
+
} finally {
|
|
345
|
+
// Defensive: never leave an awaiter of firstJoin hanging if the loop exits.
|
|
346
|
+
settleFirstReject(new Error("control stream ended before a join event"));
|
|
347
|
+
}
|
|
348
|
+
})();
|
|
349
|
+
|
|
350
|
+
return { firstJoin, done };
|
|
351
|
+
}
|
package/lib/conversation.ts
CHANGED
|
@@ -26,10 +26,24 @@ export class ReattachFailedError extends Error {
|
|
|
26
26
|
}
|
|
27
27
|
}
|
|
28
28
|
|
|
29
|
+
/**
|
|
30
|
+
* Non-2xx from the conversation mint/join endpoint. `.status` lets the caller
|
|
31
|
+
* react to a 401 (the instance_token was revoked → re-acquire a new instance
|
|
32
|
+
* and retry, #269) distinctly from other failures.
|
|
33
|
+
*/
|
|
34
|
+
export class ConversationMintError extends Error {
|
|
35
|
+
status: number;
|
|
36
|
+
constructor(status: number, detail: string) {
|
|
37
|
+
super(`Failed to mint conversation token (HTTP ${status}): ${detail}`);
|
|
38
|
+
this.name = "ConversationMintError";
|
|
39
|
+
this.status = status;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
29
43
|
export async function mintConversation(
|
|
30
44
|
baseUrl: string,
|
|
31
45
|
projectId: number,
|
|
32
|
-
|
|
46
|
+
instanceToken: string,
|
|
33
47
|
fingerprint: string,
|
|
34
48
|
label: string,
|
|
35
49
|
/**
|
|
@@ -55,7 +69,7 @@ export async function mintConversation(
|
|
|
55
69
|
const resp = await fetch(`${baseUrl}/api/projects/${projectId}/conversations`, {
|
|
56
70
|
method: "POST",
|
|
57
71
|
headers: {
|
|
58
|
-
Authorization: `Bearer ${
|
|
72
|
+
Authorization: `Bearer ${instanceToken}`,
|
|
59
73
|
"X-Client-Fingerprint": fingerprint,
|
|
60
74
|
"Content-Type": "application/json",
|
|
61
75
|
...cfAccessHeaders(),
|
|
@@ -64,7 +78,7 @@ export async function mintConversation(
|
|
|
64
78
|
});
|
|
65
79
|
if (!resp.ok) {
|
|
66
80
|
const detail = await resp.text();
|
|
67
|
-
throw new
|
|
81
|
+
throw new ConversationMintError(resp.status, detail);
|
|
68
82
|
}
|
|
69
83
|
return (await resp.json()) as ConversationMintResponse;
|
|
70
84
|
}
|
|
@@ -81,14 +95,14 @@ export async function mintConversation(
|
|
|
81
95
|
export async function reattachConversation(
|
|
82
96
|
baseUrl: string,
|
|
83
97
|
projectId: number,
|
|
84
|
-
|
|
98
|
+
instanceToken: string,
|
|
85
99
|
fingerprint: string,
|
|
86
100
|
conversationId: string,
|
|
87
101
|
): Promise<ConversationMintResponse> {
|
|
88
102
|
const resp = await fetch(`${baseUrl}/api/projects/${projectId}/conversations`, {
|
|
89
103
|
method: "POST",
|
|
90
104
|
headers: {
|
|
91
|
-
Authorization: `Bearer ${
|
|
105
|
+
Authorization: `Bearer ${instanceToken}`,
|
|
92
106
|
"X-Client-Fingerprint": fingerprint,
|
|
93
107
|
"Content-Type": "application/json",
|
|
94
108
|
...cfAccessHeaders(),
|
|
@@ -237,7 +251,7 @@ export const DEFAULT_REATTACH_WINDOW_SECONDS = 3600;
|
|
|
237
251
|
* @param sessionPath Path returned by `sessionFilePath(baseUrl, fingerprint, sessionId, dir)`
|
|
238
252
|
* @param baseUrl Backend base URL
|
|
239
253
|
* @param projectId Project ID from auth.json
|
|
240
|
-
* @param
|
|
254
|
+
* @param instanceToken Bearer token from auth.json
|
|
241
255
|
* @param fingerprint Client fingerprint from auth.json
|
|
242
256
|
* @param label Conversation label (used only when minting)
|
|
243
257
|
* @param targetConversationId Optional target for the attach-to-project flow (passed to mint)
|
|
@@ -248,7 +262,7 @@ export async function acquireConversation(
|
|
|
248
262
|
sessionPath: string,
|
|
249
263
|
baseUrl: string,
|
|
250
264
|
projectId: number,
|
|
251
|
-
|
|
265
|
+
instanceToken: string,
|
|
252
266
|
fingerprint: string,
|
|
253
267
|
label: string,
|
|
254
268
|
targetConversationId?: string | null,
|
|
@@ -265,7 +279,7 @@ export async function acquireConversation(
|
|
|
265
279
|
if (handleAge < reattachWindowSeconds) {
|
|
266
280
|
log(`[viber-channel] startup: session handle found (conv_id=${handle.conversation_id}, age=${handleAge}s < ${reattachWindowSeconds}s window), attempting reattach\n`);
|
|
267
281
|
try {
|
|
268
|
-
const result = await reattachConversation(baseUrl, projectId,
|
|
282
|
+
const result = await reattachConversation(baseUrl, projectId, instanceToken, fingerprint, handle.conversation_id);
|
|
269
283
|
log(`[viber-channel] startup: reattach OK, conv_id=${result.conversation_id}\n`);
|
|
270
284
|
try {
|
|
271
285
|
writeHandle(sessionPath, result.conversation_id);
|
|
@@ -291,7 +305,7 @@ export async function acquireConversation(
|
|
|
291
305
|
}
|
|
292
306
|
|
|
293
307
|
// Mint a new conversation
|
|
294
|
-
const result = await mintConversation(baseUrl, projectId,
|
|
308
|
+
const result = await mintConversation(baseUrl, projectId, instanceToken, fingerprint, label, targetConversationId ?? null);
|
|
295
309
|
log(`[viber-channel] startup: mint OK, conv_id=${result.conversation_id}\n`);
|
|
296
310
|
try {
|
|
297
311
|
writeHandle(sessionPath, result.conversation_id);
|