@buz-extensions/buz 1.0.0-beta.17 → 1.0.0-beta.18

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/index.ts CHANGED
@@ -1,6 +1,8 @@
1
1
  import type { ChannelPlugin, OpenClawPluginApi } from "openclaw/plugin-sdk/line";
2
2
  import { emptyPluginConfigSchema, buildChannelConfigSchema } from "openclaw/plugin-sdk/line";
3
3
  import { z } from "zod";
4
+ import { normalizeBuzTarget, looksLikeBuzId } from "./src/targets.js";
5
+ import { resolveBuzOutboundSessionRoute } from "./src/session-route.js";
4
6
 
5
7
  const BuzAccountSchema = z
6
8
  .object({
@@ -48,7 +50,25 @@ export const buzChannelPlugin = {
48
50
  threads: true,
49
51
  media: false,
50
52
  },
51
- configSchema: buildChannelConfigSchema(BuzConfigSchema),
53
+ configSchema: {
54
+ ...buildChannelConfigSchema(BuzConfigSchema),
55
+ uiHints: {
56
+ serverAddress: {
57
+ label: "Server Address",
58
+ },
59
+ secretKey: {
60
+ label: "Secret Key",
61
+ sensitive: false,
62
+ },
63
+ "accounts.*.serverAddress": {
64
+ label: "Server Address",
65
+ },
66
+ "accounts.*.secretKey": {
67
+ label: "Secret Key",
68
+ sensitive: false,
69
+ },
70
+ },
71
+ },
52
72
  config: {
53
73
  listAccountIds: (cfg: any) => {
54
74
  const accounts = cfg?.channels?.["buz"]?.accounts || {};
@@ -90,12 +110,17 @@ export const buzChannelPlugin = {
90
110
  }),
91
111
  },
92
112
  messaging: {
93
- normalizeTarget: (target: string) => target,
113
+ normalizeTarget: (target: string) => normalizeBuzTarget(target) ?? undefined,
94
114
  targetResolver: {
95
- looksLikeId: (_id: string) => true,
115
+ looksLikeId: looksLikeBuzId,
96
116
  hint: "<targetId>",
97
117
  },
98
- resolveSessionTarget: ({ id }: any) => id,
118
+ resolveSessionTarget: ({ kind, id }: any) => {
119
+ if (kind === "group") return `buz:group:${id}`;
120
+ return `buz:user:${id}`;
121
+ },
122
+ resolveOutboundSessionRoute: (params: any) => resolveBuzOutboundSessionRoute(params),
123
+
99
124
  },
100
125
  setup: {
101
126
  validateInput: async (params: any) => {
@@ -112,6 +137,20 @@ export const buzChannelPlugin = {
112
137
  deliveryMode: "direct",
113
138
  chunker: null,
114
139
  textChunkLimit: 4000,
140
+ resolveTarget: ({ to }: { to?: string; cfg?: any; allowFrom?: string[]; accountId?: string | null; mode?: "explicit" | "implicit" }) => {
141
+ const trimmed = to?.trim() ?? "";
142
+ if (!trimmed) {
143
+ return { ok: true, to: "" };
144
+ }
145
+ const normalized = normalizeBuzTarget(trimmed);
146
+ if (!normalized) {
147
+ return {
148
+ ok: false,
149
+ error: new Error("Delivering to buz requires a valid target <targetId> when delivery.to is specified."),
150
+ };
151
+ }
152
+ return { ok: true, to: normalized };
153
+ },
115
154
  sendText: async ({ to, text, accountId, replyToId, threadId, cfg }: any) => {
116
155
  const { sendText } = await import("./src/outbound.js");
117
156
  return await sendText({ to, text, accountId, replyToId, threadId, cfg });
@@ -1,6 +1,8 @@
1
1
  {
2
2
  "id": "buz",
3
- "channels": ["buz"],
3
+ "channels": [
4
+ "buz"
5
+ ],
4
6
  "name": "buz Plugin",
5
7
  "description": "Connects OpenClaw to buz",
6
8
  "enabledByDefault": true,
@@ -36,5 +38,21 @@
36
38
  }
37
39
  }
38
40
  }
41
+ },
42
+ "uiHints": {
43
+ "serverAddress": {
44
+ "label": "Server Address"
45
+ },
46
+ "secretKey": {
47
+ "label": "Secret Key",
48
+ "sensitive": false
49
+ },
50
+ "accounts.*.serverAddress": {
51
+ "label": "Server Address"
52
+ },
53
+ "accounts.*.secretKey": {
54
+ "label": "Secret Key",
55
+ "sensitive": false
56
+ }
39
57
  }
40
58
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@buz-extensions/buz",
3
- "version": "1.0.0-beta.17",
3
+ "version": "1.0.0-beta.18",
4
4
  "description": "OpenClaw buz channel plugin",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/src/gateway.ts CHANGED
@@ -10,6 +10,11 @@ const __dirname = dirname(__filename);
10
10
 
11
11
  export const activeStreams = new Map<string, grpc.ClientDuplexStream<any, any>>();
12
12
  export const activeClients = new Map<string, any>();
13
+ const streamReadyState = new Map<string, boolean>();
14
+ const streamWaiters = new Map<
15
+ string,
16
+ Array<{ resolve: (stream: grpc.ClientDuplexStream<any, any>) => void; reject: (err: Error) => void }>
17
+ >();
13
18
 
14
19
  function setStatus(ctx: any, patch: Record<string, unknown>) {
15
20
  console.log(`[buz gateway] setStatus called:`, patch);
@@ -19,6 +24,66 @@ function setStatus(ctx: any, patch: Record<string, unknown>) {
19
24
  });
20
25
  }
21
26
 
27
+ function resolvePendingWaiters(accountId: string, stream: grpc.ClientDuplexStream<any, any>) {
28
+ const waiters = streamWaiters.get(accountId);
29
+ if (!waiters?.length) {
30
+ return;
31
+ }
32
+ streamWaiters.delete(accountId);
33
+ for (const waiter of waiters) {
34
+ waiter.resolve(stream);
35
+ }
36
+ }
37
+
38
+ function rejectPendingWaiters(accountId: string, err: Error) {
39
+ const waiters = streamWaiters.get(accountId);
40
+ if (!waiters?.length) {
41
+ return;
42
+ }
43
+ streamWaiters.delete(accountId);
44
+ for (const waiter of waiters) {
45
+ waiter.reject(err);
46
+ }
47
+ }
48
+
49
+ export function isStreamReady(accountId: string): boolean {
50
+ return streamReadyState.get(accountId) === true && activeStreams.has(accountId);
51
+ }
52
+
53
+ export async function waitForReadyStream(
54
+ accountId: string,
55
+ timeoutMs = 3000,
56
+ ): Promise<grpc.ClientDuplexStream<any, any>> {
57
+ const existing = activeStreams.get(accountId);
58
+ if (existing && isStreamReady(accountId)) {
59
+ return existing;
60
+ }
61
+
62
+ return await new Promise<grpc.ClientDuplexStream<any, any>>((resolve, reject) => {
63
+ const timer = setTimeout(() => {
64
+ const waiters = streamWaiters.get(accountId) ?? [];
65
+ streamWaiters.set(
66
+ accountId,
67
+ waiters.filter((entry) => entry.resolve !== wrappedResolve),
68
+ );
69
+ reject(new Error(`[buz] Timed out waiting for ready gRPC stream for account ${accountId}`));
70
+ }, timeoutMs);
71
+
72
+ const wrappedResolve = (stream: grpc.ClientDuplexStream<any, any>) => {
73
+ clearTimeout(timer);
74
+ resolve(stream);
75
+ };
76
+ const wrappedReject = (err: Error) => {
77
+ clearTimeout(timer);
78
+ reject(err);
79
+ };
80
+
81
+ const current = streamWaiters.get(accountId) ?? [];
82
+ current.push({ resolve: wrappedResolve, reject: wrappedReject });
83
+ streamWaiters.set(accountId, current);
84
+ });
85
+ }
86
+
22
87
  export async function startGateway(ctx: any, serverAddress: string, secretKey: string) {
23
88
  const PROTO_PATH = resolve(__dirname, "../proto/buz.proto");
24
89
 
@@ -71,6 +136,8 @@ export async function startGateway(ctx: any, serverAddress: string, secretKey: s
71
136
  let reconnectAttempts = 0;
72
137
  let everConnected = false;
73
138
  let isActive = true;
139
+ let isConnecting = false;
140
+ let connectionGeneration = 0;
74
141
  let pingInterval: NodeJS.Timeout | null = null;
75
142
  let reconnectTimer: NodeJS.Timeout | null = null;
76
143
 
@@ -80,31 +147,53 @@ export async function startGateway(ctx: any, serverAddress: string, secretKey: s
80
147
  clearInterval(pingInterval);
81
148
  pingInterval = null;
82
149
  }
150
+ streamReadyState.set(accountId, false);
151
+ activeStreams.delete(accountId);
83
152
  if (stream) {
84
- activeStreams.delete(accountId);
85
- stream.removeAllListeners();
153
+ const current = stream;
86
154
  stream = null;
155
+ current.removeAllListeners();
156
+ try {
157
+ current.cancel();
158
+ } catch (err: any) {
159
+ console.warn("[buz gateway] stream cancel during cleanup failed:", err?.message || String(err));
160
+ }
161
+ }
162
+ };
163
+
164
+ const clearReconnectTimer = () => {
165
+ if (reconnectTimer) {
166
+ clearTimeout(reconnectTimer);
167
+ reconnectTimer = null;
87
168
  }
88
169
  };
89
170
 
90
171
  const scheduleReconnect = (reason?: string) => {
91
172
  console.log(`[buz gateway] scheduleReconnect called, reason: ${reason || "none"}`);
173
+ isConnecting = false;
174
+ connectionGeneration += 1;
92
175
  cleanupStream();
93
- // Keep running: true during reconnect attempts
176
+ rejectPendingWaiters(
177
+ accountId,
178
+ new Error(`[buz] gRPC stream unavailable for account ${accountId}${reason ? `: ${reason}` : ""}`),
179
+ );
94
180
  setStatus(ctx, {
95
181
  connected: false,
96
- running: true, // Still trying to run
182
+ running: true,
97
183
  ...(reason ? { lastError: reason } : {}),
98
184
  });
99
185
 
100
186
  if (!isActive || ctx.abortSignal?.aborted) {
101
187
  console.log("[buz gateway] aborting reconnect (isActive=false or aborted)");
188
+ clearReconnectTimer();
102
189
  return;
103
190
  }
104
191
 
192
+ clearReconnectTimer();
193
+ const nextAttempt = reconnectAttempts + 1;
105
194
  const delay = Math.min(1000 * Math.pow(2, reconnectAttempts), 30000);
106
- reconnectAttempts += 1;
107
- console.log(`[buz gateway] reconnecting in ${delay}ms (attempt ${reconnectAttempts})`);
195
+ reconnectAttempts = nextAttempt;
196
+ console.log(`[buz gateway] reconnecting in ${delay}ms (attempt ${nextAttempt})`);
108
197
  ctx.log?.warn?.(
109
198
  `[${accountId}] buz gRPC disconnected${reason ? `: ${reason}` : ""}; reconnecting in ${delay}ms`,
110
199
  );
@@ -120,15 +209,21 @@ export async function startGateway(ctx: any, serverAddress: string, secretKey: s
120
209
  console.log("[buz gateway] aborting connect (isActive=false or aborted)");
121
210
  return;
122
211
  }
212
+ if (isConnecting) {
213
+ console.log("[buz gateway] connect skipped: already connecting");
214
+ return;
215
+ }
123
216
 
217
+ clearReconnectTimer();
218
+ isConnecting = true;
219
+ const generation = ++connectionGeneration;
124
220
  cleanupStream();
125
221
  setStatus(ctx, {
126
222
  connected: false,
127
- running: true, // Mark as running while connecting
223
+ running: true,
128
224
  lastError: everConnected ? null : "connecting",
129
225
  });
130
226
 
131
- // Create metadata with authorization header
132
227
  const metadata = new grpc.Metadata();
133
228
  metadata.add("authorization", `Bearer ${secretKey}`);
134
229
  metadata.add("x-openclaw-id", accountId);
@@ -138,6 +233,7 @@ export async function startGateway(ctx: any, serverAddress: string, secretKey: s
138
233
  stream = client.ConnectStream(metadata);
139
234
  console.log("[buz gateway] stream created:", !!stream);
140
235
  } catch (err: any) {
236
+ isConnecting = false;
141
237
  console.error("[buz gateway] failed to create gRPC stream:", err?.message || String(err));
142
238
  ctx.log?.error?.(
143
239
  `[${accountId}] failed to create gRPC stream: ${err?.message || String(err)}`,
@@ -147,16 +243,15 @@ export async function startGateway(ctx: any, serverAddress: string, secretKey: s
147
243
  }
148
244
 
149
245
  if (!stream) {
246
+ isConnecting = false;
150
247
  console.error("[buz gateway] stream is null after creation");
151
248
  ctx.log?.error?.(`[${accountId}] failed to create gRPC stream: stream is null`);
152
249
  scheduleReconnect("stream is null");
153
250
  return;
154
251
  }
155
252
 
156
- activeStreams.set(accountId, stream);
157
- console.log("[buz gateway] stream stored, total active streams:", activeStreams.size);
253
+ streamReadyState.set(accountId, false);
158
254
 
159
- // Send auth request via message body (for backward compatibility)
160
255
  const authRequest = {
161
256
  auth_req: {
162
257
  secret_key: secretKey,
@@ -167,19 +262,25 @@ export async function startGateway(ctx: any, serverAddress: string, secretKey: s
167
262
  try {
168
263
  stream.write(authRequest);
169
264
  } catch (err: any) {
265
+ isConnecting = false;
170
266
  console.error("[buz gateway] failed to send auth request:", err?.message);
171
- // Don't schedule reconnect here, let the stream error handler deal with it
267
+ scheduleReconnect(err?.message || "failed to send auth request");
268
+ return;
172
269
  }
173
270
 
174
271
  stream.on("data", async (msg: any) => {
175
- console.log("[buz gateway] stream received data:", Object.keys(msg));
176
-
272
+ if (generation !== connectionGeneration) {
273
+ console.log("[buz gateway] ignoring data from stale generation", generation, connectionGeneration);
274
+ return;
275
+ }
177
276
  if (msg.auth_res) {
178
- console.log("[buz gateway] received auth_res:", msg.auth_res);
179
277
  if (msg.auth_res.success) {
278
+ isConnecting = false;
180
279
  everConnected = true;
181
280
  reconnectAttempts = 0;
182
- // IMPORTANT: Must include running: true
281
+ activeStreams.set(accountId, stream!);
282
+ streamReadyState.set(accountId, true);
283
+ resolvePendingWaiters(accountId, stream!);
183
284
  setStatus(ctx, {
184
285
  running: true,
185
286
  connected: true,
@@ -191,8 +292,7 @@ export async function startGateway(ctx: any, serverAddress: string, secretKey: s
191
292
 
192
293
  if (pingInterval) clearInterval(pingInterval);
193
294
  pingInterval = setInterval(() => {
194
- console.log("[buz gateway] sending heartbeat");
195
- if (stream) {
295
+ if (stream && streamReadyState.get(accountId) === true) {
196
296
  try {
197
297
  stream.write({
198
298
  ping: { timestamp: Date.now() },
@@ -206,6 +306,7 @@ export async function startGateway(ctx: any, serverAddress: string, secretKey: s
206
306
  }, 30000);
207
307
  console.log("[buz gateway] heartbeat interval set (30s)");
208
308
  } else {
309
+ isConnecting = false;
209
310
  const reason = `Auth failed: ${msg.auth_res.error_message || "unknown error"}`;
210
311
  console.error("[buz gateway] authentication failed:", reason);
211
312
  ctx.log?.error?.(`[${accountId}] ${reason}`);
@@ -216,7 +317,6 @@ export async function startGateway(ctx: any, serverAddress: string, secretKey: s
216
317
 
217
318
  if (msg.inbound_msg) {
218
319
  console.log("[buz gateway] received inbound message");
219
- // Update lastInboundAt when receiving messages
220
320
  setStatus(ctx, {
221
321
  lastInboundAt: Date.now(),
222
322
  running: true,
@@ -234,11 +334,21 @@ export async function startGateway(ctx: any, serverAddress: string, secretKey: s
234
334
  });
235
335
 
236
336
  stream.on("end", () => {
337
+ if (generation !== connectionGeneration) {
338
+ console.log("[buz gateway] ignoring end from stale generation", generation, connectionGeneration);
339
+ return;
340
+ }
341
+ isConnecting = false;
237
342
  console.log("[buz gateway] stream ended");
238
343
  scheduleReconnect("stream ended");
239
344
  });
240
345
 
241
346
  stream.on("error", (err: any) => {
347
+ if (generation !== connectionGeneration) {
348
+ console.log("[buz gateway] ignoring error from stale generation", generation, connectionGeneration);
349
+ return;
350
+ }
351
+ isConnecting = false;
242
352
  const reason = err?.message || String(err);
243
353
  console.error("[buz gateway] stream error:", reason);
244
354
  ctx.log?.error?.(`[${accountId}] gRPC stream error: ${reason}`);
@@ -250,7 +360,7 @@ export async function startGateway(ctx: any, serverAddress: string, secretKey: s
250
360
 
251
361
  console.log("[buz gateway] initializing connection...");
252
362
  setStatus(ctx, {
253
- running: true, // Mark as running from the start
363
+ running: true,
254
364
  connected: false,
255
365
  lastError: null,
256
366
  lastStartAt: Date.now(),
@@ -265,12 +375,8 @@ export async function startGateway(ctx: any, serverAddress: string, secretKey: s
265
375
  clearTimeout(reconnectTimer);
266
376
  reconnectTimer = null;
267
377
  }
378
+ rejectPendingWaiters(accountId, new Error(`[buz] gateway aborted for account ${accountId}`));
268
379
  cleanupStream();
269
- const activeStream = activeStreams.get(accountId);
270
- if (activeStream) {
271
- activeStream.cancel();
272
- activeStreams.delete(accountId);
273
- }
274
380
  const activeClient = activeClients.get(accountId);
275
381
  if (activeClient) {
276
382
  activeClient.close();
@@ -296,10 +402,17 @@ export async function stopGateway(ctx: any) {
296
402
  const accountId = ctx.account?.accountId || "default";
297
403
  console.log("[buz gateway] stopping account:", accountId);
298
404
 
405
+ streamReadyState.set(accountId, false);
406
+ rejectPendingWaiters(accountId, new Error(`[buz] stop requested for account ${accountId}`));
407
+
299
408
  const stream = activeStreams.get(accountId);
300
409
  if (stream) {
301
410
  console.log("[buz gateway] cancelling stream");
302
- stream.cancel();
411
+ try {
412
+ stream.cancel();
413
+ } catch (err: any) {
414
+ console.warn("[buz gateway] stopGateway cancel failed:", err?.message || String(err));
415
+ }
303
416
  activeStreams.delete(accountId);
304
417
  }
305
418
 
package/src/inbound.ts CHANGED
@@ -58,7 +58,7 @@ async function emitIntermediate(params: {
58
58
  text?: string;
59
59
  }) {
60
60
  const { toTarget, accountId, messageSid, type, event, text } = params;
61
- if (!text && type !== "assistant_message_start") {
61
+ if (!text && type !== "assistant_message_start" && !(type === "reasoning" && event === "done")) {
62
62
  return;
63
63
  }
64
64
  await sendText({
@@ -139,7 +139,29 @@ export async function handleInboundMessage(ctx: any, inboundMsg: any) {
139
139
  console.log("[buz inbound] storePath:", storePath);
140
140
  console.log("[buz inbound] dispatching via recordInboundSessionAndDispatchReply...");
141
141
 
142
+ let streamEventQueue = Promise.resolve();
143
+ let lastPartialText = "";
144
+ let lastReasoningText = "";
145
+ const enqueueStreamEvent = (task: () => Promise<void>) => {
146
+ const next = streamEventQueue.then(task);
147
+ streamEventQueue = next.catch((err) => {
148
+ console.error("[buz inbound] stream event failed:", err);
149
+ });
150
+ return next;
151
+ };
152
+
142
153
  try {
154
+ await core.channel.session.updateLastRoute({
155
+ storePath,
156
+ sessionKey: ctxPayload.SessionKey ?? sessionKey,
157
+ deliveryContext: {
158
+ channel: "buz",
159
+ to: isGroup ? `buz:group:${conversationId}` : `buz:user:${senderId}`,
160
+ accountId,
161
+ },
162
+ ctx: ctxPayload,
163
+ });
164
+
143
165
  await recordInboundSessionAndDispatchReply({
144
166
  cfg,
145
167
  channel: "buz",
@@ -151,19 +173,21 @@ export async function handleInboundMessage(ctx: any, inboundMsg: any) {
151
173
  recordInboundSession: core.channel.session.recordInboundSession,
152
174
  dispatchReplyWithBufferedBlockDispatcher: core.channel.reply.dispatchReplyWithBufferedBlockDispatcher,
153
175
  deliver: async (payload: any) => {
154
- console.log("[buz inbound] deliver called text:", payload?.text?.substring?.(0, 50));
155
- if (!payload?.text) {
156
- return;
157
- }
158
- await sendText({
159
- to: toTarget,
160
- text: payload.text,
161
- accountId,
162
- replyToId: messageSid,
163
- type: payload?.isReasoning ? "reasoning" : "final_reply",
164
- event: "done",
176
+ await enqueueStreamEvent(async () => {
177
+ console.log("[buz inbound] deliver called text:", payload?.text?.substring?.(0, 50));
178
+ if (!payload?.text) {
179
+ return;
180
+ }
181
+ await sendText({
182
+ to: toTarget,
183
+ text: payload.text,
184
+ accountId,
185
+ replyToId: messageSid,
186
+ type: payload?.isReasoning ? "reasoning" : "final_reply",
187
+ event: "done",
188
+ });
189
+ console.log("[buz inbound] reply sent successfully via gRPC");
165
190
  });
166
- console.log("[buz inbound] reply sent successfully via gRPC");
167
191
  },
168
192
  onRecordError: (err: any) => {
169
193
  console.error("[buz inbound] failed to record session:", err);
@@ -174,60 +198,83 @@ export async function handleInboundMessage(ctx: any, inboundMsg: any) {
174
198
  replyOptions: {
175
199
  disableBlockStreaming: false,
176
200
  onPartialReply: async (payload: any) => {
177
- await emitIntermediate({
178
- toTarget,
179
- accountId,
180
- messageSid,
181
- type: "partial_reply",
182
- event: "delta",
183
- text: payload?.text,
201
+ const text = String(payload?.text || "");
202
+ if (!text || text === lastPartialText) {
203
+ return;
204
+ }
205
+ lastPartialText = text;
206
+ await enqueueStreamEvent(async () => {
207
+ await emitIntermediate({
208
+ toTarget,
209
+ accountId,
210
+ messageSid,
211
+ type: "partial_reply",
212
+ event: "delta",
213
+ text,
214
+ });
184
215
  });
185
216
  },
186
217
  onReasoningStream: async (payload: any) => {
187
- await emitIntermediate({
188
- toTarget,
189
- accountId,
190
- messageSid,
191
- type: "reasoning",
192
- event: "delta",
193
- text: payload?.text,
218
+ const text = String(payload?.text || "");
219
+ if (!text || text === lastReasoningText) {
220
+ return;
221
+ }
222
+ lastReasoningText = text;
223
+ await enqueueStreamEvent(async () => {
224
+ await emitIntermediate({
225
+ toTarget,
226
+ accountId,
227
+ messageSid,
228
+ type: "reasoning",
229
+ event: "delta",
230
+ text,
231
+ });
194
232
  });
195
233
  },
196
234
  onAssistantMessageStart: async () => {
197
- await emitIntermediate({
198
- toTarget,
199
- accountId,
200
- messageSid,
201
- type: "assistant_message_start",
202
- event: "start",
203
- text: "",
235
+ await enqueueStreamEvent(async () => {
236
+ lastPartialText = "";
237
+ await emitIntermediate({
238
+ toTarget,
239
+ accountId,
240
+ messageSid,
241
+ type: "assistant_message_start",
242
+ event: "start",
243
+ text: "",
244
+ });
204
245
  });
205
246
  },
206
247
  onReasoningEnd: async () => {
207
- await emitIntermediate({
208
- toTarget,
209
- accountId,
210
- messageSid,
211
- type: "reasoning",
212
- event: "done",
213
- text: "",
248
+ await enqueueStreamEvent(async () => {
249
+ lastReasoningText = "";
250
+ await emitIntermediate({
251
+ toTarget,
252
+ accountId,
253
+ messageSid,
254
+ type: "reasoning",
255
+ event: "done",
256
+ text: "",
257
+ });
214
258
  });
215
259
  },
216
260
  onToolStart: async (payload: any) => {
217
261
  const toolName = String(payload?.name || "tool").trim() || "tool";
218
262
  const phase = String(payload?.phase || "start").trim() || "start";
219
- await emitIntermediate({
220
- toTarget,
221
- accountId,
222
- messageSid,
223
- type: "tool_start",
224
- event: "start",
225
- text: `${toolName}${phase ? ` (${phase})` : ""}`,
263
+ await enqueueStreamEvent(async () => {
264
+ await emitIntermediate({
265
+ toTarget,
266
+ accountId,
267
+ messageSid,
268
+ type: "tool_start",
269
+ event: "start",
270
+ text: `${toolName}${phase ? ` (${phase})` : ""}`,
271
+ });
226
272
  });
227
273
  },
228
274
  },
229
275
  });
230
276
 
277
+ await streamEventQueue;
231
278
  console.log("[buz inbound] message dispatched successfully");
232
279
  ctx.log?.info?.(
233
280
  `[${accountId}] Successfully dispatched inbound message from ${inboundMsg.sender_id}`,
package/src/outbound.ts CHANGED
@@ -1,11 +1,12 @@
1
- import { activeStreams } from "./gateway.js";
1
+ import { activeStreams, isStreamReady, waitForReadyStream } from "./gateway.js";
2
+ import { getBuzRuntime } from "../index.js";
2
3
 
3
4
  function resolveTarget(to: string) {
4
5
  let chatType = "direct";
5
- let targetId = to;
6
+ let targetId = (to || "").trim();
6
7
 
7
- if (to.startsWith("buz:")) {
8
- targetId = to.substring("buz:".length);
8
+ if (targetId.startsWith("buz:")) {
9
+ targetId = targetId.substring("buz:".length);
9
10
  }
10
11
 
11
12
  if (targetId.startsWith("group:")) {
@@ -21,39 +22,56 @@ function resolveTarget(to: string) {
21
22
  return { chatType, targetId };
22
23
  }
23
24
 
24
- export async function sendText(params: any) {
25
- const { to, text, accountId, replyToId, type = "final_reply", event } = params;
25
+ function markLastOutboundAt(accountId: string) {
26
+ try {
27
+ const runtime = getBuzRuntime();
28
+ const helper = runtime?.channel?.status?.setAccountRuntimePatch;
29
+ if (typeof helper === "function") {
30
+ helper({
31
+ channel: "buz",
32
+ accountId,
33
+ patch: { lastOutboundAt: Date.now() },
34
+ });
35
+ }
36
+ } catch {
37
+ // best effort only
38
+ }
39
+ }
26
40
 
27
- console.log("[buz outbound] =========================================");
28
- console.log("[buz outbound] sendText called");
29
- console.log("[buz outbound] to:", to);
30
- console.log("[buz outbound] type:", type);
31
- console.log("[buz outbound] event:", event);
32
- console.log("[buz outbound] text preview:", text?.substring?.(0, 100));
33
- console.log("[buz outbound] text length:", text?.length);
34
- console.log("[buz outbound] accountId:", accountId);
35
- console.log("[buz outbound] replyToId:", replyToId);
41
+ async function resolveWritableStream(accountId: string) {
42
+ const existing = activeStreams.get(accountId);
43
+ if (existing && isStreamReady(accountId)) {
44
+ return existing;
45
+ }
46
+ return await waitForReadyStream(accountId, 3000);
47
+ }
36
48
 
37
- const targetAccountId = accountId || "default";
38
- const stream = activeStreams.get(targetAccountId);
49
+ export async function sendText(params: any) {
50
+ const { to, text, accountId, replyToId, type = "final_reply", event } = params;
39
51
 
40
- console.log("[buz outbound] targetAccountId:", targetAccountId);
41
- console.log("[buz outbound] activeStreams size:", activeStreams.size);
42
- console.log("[buz outbound] stream found:", !!stream);
52
+ console.log("[buz outbound] sendText called:", JSON.stringify({ to, accountId, type, textLength: text?.length }));
43
53
 
44
- if (!stream) {
45
- console.error("[buz outbound] ERROR: No active gRPC stream for account", targetAccountId);
46
- console.log("[buz outbound] activeStreams keys:", Array.from(activeStreams.keys()));
47
- throw new Error(`[buz] No active gRPC stream for account ${targetAccountId}`);
48
- }
54
+ const targetAccountId = String(accountId || "default").trim() || "default";
55
+ const effectiveTo = String(to || "").trim();
56
+ const hasExplicitTarget = Boolean(effectiveTo);
57
+ const { chatType, targetId } = resolveTarget(effectiveTo);
49
58
 
50
- const { chatType, targetId } = resolveTarget(to);
59
+ console.log(
60
+ "[buz outbound] resolved:",
61
+ JSON.stringify({
62
+ chatType,
63
+ targetId,
64
+ originalTo: to,
65
+ effectiveTo,
66
+ routingMode: hasExplicitTarget ? "explicit-target" : "server-default-target",
67
+ }),
68
+ );
51
69
 
52
70
  const outboundMsg = {
53
71
  outbound_msg: {
54
72
  reply_to_id: replyToId || "",
55
- target_id: targetId,
56
- chat_type: chatType,
73
+ target_id: targetId || "",
74
+ chat_type: hasExplicitTarget ? chatType : "",
57
75
  content_text: text || "",
58
76
  type,
59
77
  event: event || "",
@@ -62,13 +80,24 @@ export async function sendText(params: any) {
62
80
 
63
81
  console.log("[buz outbound] outboundMsg:", JSON.stringify(outboundMsg, null, 2));
64
82
 
65
- try {
66
- stream.write(outboundMsg);
67
- console.log("[buz outbound] gRPC stream write successful");
68
- } catch (err: any) {
69
- console.error("[buz outbound] ERROR writing to stream:", err.message);
70
- throw err;
83
+ let lastErr: unknown;
84
+ for (let attempt = 1; attempt <= 2; attempt += 1) {
85
+ try {
86
+ const stream = await resolveWritableStream(targetAccountId);
87
+ stream.write(outboundMsg);
88
+ markLastOutboundAt(targetAccountId);
89
+ console.log(`[buz outbound] gRPC stream write successful (attempt ${attempt})`);
90
+ return { messageId: `msg-${Date.now()}`, chatId: effectiveTo, type, event };
91
+ } catch (err: any) {
92
+ lastErr = err;
93
+ console.error(`[buz outbound] ERROR writing to stream (attempt ${attempt}):`, err?.message || String(err));
94
+ if (attempt < 2) {
95
+ await new Promise((resolve) => setTimeout(resolve, 600));
96
+ }
97
+ }
71
98
  }
72
99
 
73
- return { messageId: `msg-${Date.now()}`, chatId: to, type, event };
100
+ throw lastErr instanceof Error
101
+ ? lastErr
102
+ : new Error(`[buz] Failed to send outbound message for account ${targetAccountId}`);
74
103
  }
@@ -0,0 +1,36 @@
1
+ import { getBuzRuntime } from "../index.js";
2
+ import { parseBuzTarget } from "./targets.js";
3
+
4
+ export function resolveBuzOutboundSessionRoute(params: any) {
5
+ const parsed = parseBuzTarget(String(params?.target || ""));
6
+ if (!parsed) {
7
+ return null;
8
+ }
9
+
10
+ const runtime = getBuzRuntime();
11
+ const buildAgentSessionKey = runtime?.channel?.routing?.buildAgentSessionKey;
12
+ const chatType = parsed.kind === "group" ? "group" : "direct";
13
+ const accountId = String(params?.accountId || "default");
14
+ const baseSessionKey =
15
+ typeof buildAgentSessionKey === "function"
16
+ ? buildAgentSessionKey({
17
+ agentId: params.agentId,
18
+ channel: "buz",
19
+ accountId,
20
+ chatType,
21
+ conversationId: parsed.id,
22
+ })
23
+ : `agent:${params.agentId}:buz:${accountId}:${chatType}:${parsed.id}`;
24
+
25
+ return {
26
+ sessionKey: baseSessionKey,
27
+ baseSessionKey,
28
+ peer: {
29
+ kind: parsed.kind === "group" ? "group" : "direct",
30
+ id: parsed.id,
31
+ },
32
+ chatType,
33
+ from: parsed.kind === "group" ? `buz:group:${parsed.id}` : `buz:${parsed.id}`,
34
+ to: parsed.kind === "group" ? `buz:group:${parsed.id}` : `buz:user:${parsed.id}`,
35
+ };
36
+ }
package/src/targets.ts ADDED
@@ -0,0 +1,46 @@
1
+ export function normalizeBuzTarget(raw: string): string | null {
2
+ const trimmed = raw.trim();
3
+ if (!trimmed) {
4
+ return null;
5
+ }
6
+
7
+ let value = trimmed;
8
+ if (/^buz:/i.test(value)) {
9
+ value = value.replace(/^buz:/i, "").trim();
10
+ }
11
+ if (!value) {
12
+ return null;
13
+ }
14
+
15
+ if (/^(group|user):/i.test(value)) {
16
+ const normalized = value.replace(/^(group|user):/i, (m) => m.toLowerCase());
17
+ return `buz:${normalized}`;
18
+ }
19
+
20
+ return `buz:user:${value}`;
21
+ }
22
+
23
+ export function looksLikeBuzId(raw: string): boolean {
24
+ const trimmed = raw.trim();
25
+ if (!trimmed) {
26
+ return false;
27
+ }
28
+ return Boolean(normalizeBuzTarget(trimmed));
29
+ }
30
+
31
+ export function parseBuzTarget(raw: string): { kind: "user" | "group"; id: string } | null {
32
+ const normalized = normalizeBuzTarget(raw);
33
+ if (!normalized) {
34
+ return null;
35
+ }
36
+ const withoutProvider = normalized.replace(/^buz:/i, "");
37
+ if (withoutProvider.startsWith("group:")) {
38
+ const id = withoutProvider.substring("group:".length).trim();
39
+ return id ? { kind: "group", id } : null;
40
+ }
41
+ if (withoutProvider.startsWith("user:")) {
42
+ const id = withoutProvider.substring("user:".length).trim();
43
+ return id ? { kind: "user", id } : null;
44
+ }
45
+ return null;
46
+ }