@buz-extensions/buz 1.0.0-beta.16 → 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.16",
3
+ "version": "1.0.0-beta.18",
4
4
  "description": "OpenClaw buz channel plugin",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/proto/buz.proto CHANGED
@@ -26,35 +26,33 @@ message BridgeToClientMessage {
26
26
  }
27
27
  }
28
28
 
29
- // --- 详细子结构 ---
30
-
31
29
  message AuthRequest {
32
- string secret_key = 1; // 用户在 OpenClaw 输入的接入凭证
33
- string openclaw_id = 2; // [可选] 客户端唯一标识
30
+ string secret_key = 1;
31
+ string openclaw_id = 2;
34
32
  }
35
33
 
36
34
  message AuthResponse {
37
35
  bool success = 1;
38
36
  string error_message = 2;
39
- string account_id = 3; // 绑定成功的 IM 账户 ID
37
+ string account_id = 3;
40
38
  }
41
39
 
42
40
  message InboundMessage {
43
- string message_id = 1; // IM 侧的全局消息 ID
44
- string sender_id = 2; // 发送者 ID
45
- string sender_name = 3; // 发送者昵称
46
- string chat_type = 4; // 枚举: "direct"(单聊), "group"(群聊)
47
- string group_id = 5; // 如果是群聊,群 ID
48
- string content_text = 6; // 文本内容
49
- // 可扩展媒体字段 (media_url 等)
41
+ string message_id = 1;
42
+ string sender_id = 2;
43
+ string sender_name = 3;
44
+ string chat_type = 4;
45
+ string group_id = 5;
46
+ string content_text = 6;
50
47
  }
51
48
 
52
49
  message OutboundMessage {
53
- string reply_to_id = 1; // 回复的目标消息 ID (Thread 追踪)
54
- string target_id = 2; // 接收方 ID (用户 ID 或 群组 ID)
55
- string chat_type = 3; // "direct" 或 "group"
56
- string content_text = 4; // AI 生成的回复文本
57
- // 可扩展媒体字段
50
+ string reply_to_id = 1;
51
+ string target_id = 2;
52
+ string chat_type = 3;
53
+ string content_text = 4;
54
+ string type = 5; // final_reply / partial_reply / reasoning / tool_start / assistant_message_start / status
55
+ string event = 6; // start / delta / done
58
56
  }
59
57
 
60
58
  message HeartbeatPing {
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
@@ -49,6 +49,28 @@ function buildSessionKey(params: {
49
49
  return `agent:${agentId}:buz:${accountId}:${isGroup ? "group" : "direct"}:${conversationId}`;
50
50
  }
51
51
 
52
+ async function emitIntermediate(params: {
53
+ toTarget: string;
54
+ accountId: string;
55
+ messageSid: string;
56
+ type: string;
57
+ event: string;
58
+ text?: string;
59
+ }) {
60
+ const { toTarget, accountId, messageSid, type, event, text } = params;
61
+ if (!text && type !== "assistant_message_start" && !(type === "reasoning" && event === "done")) {
62
+ return;
63
+ }
64
+ await sendText({
65
+ to: toTarget,
66
+ text: text || "",
67
+ accountId,
68
+ replyToId: messageSid,
69
+ type,
70
+ event,
71
+ });
72
+ }
73
+
52
74
  export async function handleInboundMessage(ctx: any, inboundMsg: any) {
53
75
  console.log("[buz inbound] =========================================");
54
76
  console.log("[buz inbound] handleInboundMessage called");
@@ -117,7 +139,29 @@ export async function handleInboundMessage(ctx: any, inboundMsg: any) {
117
139
  console.log("[buz inbound] storePath:", storePath);
118
140
  console.log("[buz inbound] dispatching via recordInboundSessionAndDispatchReply...");
119
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
+
120
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
+
121
165
  await recordInboundSessionAndDispatchReply({
122
166
  cfg,
123
167
  channel: "buz",
@@ -129,17 +173,21 @@ export async function handleInboundMessage(ctx: any, inboundMsg: any) {
129
173
  recordInboundSession: core.channel.session.recordInboundSession,
130
174
  dispatchReplyWithBufferedBlockDispatcher: core.channel.reply.dispatchReplyWithBufferedBlockDispatcher,
131
175
  deliver: async (payload: any) => {
132
- console.log("[buz inbound] deliver called text:", payload?.text?.substring?.(0, 50));
133
- if (!payload?.text) {
134
- return;
135
- }
136
- await sendText({
137
- to: toTarget,
138
- text: payload.text,
139
- accountId,
140
- replyToId: messageSid,
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");
141
190
  });
142
- console.log("[buz inbound] reply sent successfully via gRPC");
143
191
  },
144
192
  onRecordError: (err: any) => {
145
193
  console.error("[buz inbound] failed to record session:", err);
@@ -148,10 +196,85 @@ export async function handleInboundMessage(ctx: any, inboundMsg: any) {
148
196
  console.error(`[buz inbound] ${info?.kind || "unknown"} reply failed:`, err);
149
197
  },
150
198
  replyOptions: {
151
- disableBlockStreaming: true,
199
+ disableBlockStreaming: false,
200
+ onPartialReply: async (payload: any) => {
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
+ });
215
+ });
216
+ },
217
+ onReasoningStream: async (payload: any) => {
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
+ });
232
+ });
233
+ },
234
+ onAssistantMessageStart: async () => {
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
+ });
245
+ });
246
+ },
247
+ onReasoningEnd: async () => {
248
+ await enqueueStreamEvent(async () => {
249
+ lastReasoningText = "";
250
+ await emitIntermediate({
251
+ toTarget,
252
+ accountId,
253
+ messageSid,
254
+ type: "reasoning",
255
+ event: "done",
256
+ text: "",
257
+ });
258
+ });
259
+ },
260
+ onToolStart: async (payload: any) => {
261
+ const toolName = String(payload?.name || "tool").trim() || "tool";
262
+ const phase = String(payload?.phase || "start").trim() || "start";
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
+ });
272
+ });
273
+ },
152
274
  },
153
275
  });
154
276
 
277
+ await streamEventQueue;
155
278
  console.log("[buz inbound] message dispatched successfully");
156
279
  ctx.log?.info?.(
157
280
  `[${accountId}] Successfully dispatched inbound message from ${inboundMsg.sender_id}`,
package/src/outbound.ts CHANGED
@@ -1,74 +1,103 @@
1
- import { activeStreams } from "./gateway.js";
1
+ import { activeStreams, isStreamReady, waitForReadyStream } from "./gateway.js";
2
+ import { getBuzRuntime } from "../index.js";
2
3
 
3
- export async function sendText(params: any) {
4
- const { to, text, accountId, replyToId } = params;
5
-
6
- console.log("[buz outbound] =========================================");
7
- console.log("[buz outbound] sendText called");
8
- console.log("[buz outbound] to:", to);
9
- console.log("[buz outbound] text preview:", text?.substring(0, 100));
10
- console.log("[buz outbound] text length:", text?.length);
11
- console.log("[buz outbound] accountId:", accountId);
12
- console.log("[buz outbound] replyToId:", replyToId);
13
-
14
- const targetAccountId = accountId || "default";
15
- console.log("[buz outbound] targetAccountId:", targetAccountId);
16
-
17
- const stream = activeStreams.get(targetAccountId);
18
- console.log("[buz outbound] activeStreams size:", activeStreams.size);
19
- console.log("[buz outbound] stream found:", !!stream);
20
-
21
- if (!stream) {
22
- console.error("[buz outbound] ERROR: No active gRPC stream for account", targetAccountId);
23
- console.log("[buz outbound] activeStreams keys:", Array.from(activeStreams.keys()));
24
- throw new Error(`[buz] No active gRPC stream for account ${targetAccountId}`);
25
- }
26
-
27
- // to format might be: "buz:group:GROUP_ID" or "buz:USER_ID"
28
- // based on inbound parsing or explicit routing
4
+ function resolveTarget(to: string) {
29
5
  let chatType = "direct";
30
- let targetId = to;
6
+ let targetId = (to || "").trim();
31
7
 
32
- console.log("[buz outbound] parsing target:", to);
33
-
34
- if (to.startsWith("buz:")) {
35
- targetId = to.substring("buz:".length);
36
- console.log("[buz outbound] removed 'buz:' prefix, targetId:", targetId);
8
+ if (targetId.startsWith("buz:")) {
9
+ targetId = targetId.substring("buz:".length);
37
10
  }
38
11
 
39
12
  if (targetId.startsWith("group:")) {
40
13
  chatType = "group";
41
14
  targetId = targetId.substring("group:".length);
42
- console.log("[buz outbound] detected group chat, targetId:", targetId);
43
- // if targetId has a suffix like :sender_id, remove it for group target
44
15
  if (targetId.includes(":")) {
45
16
  targetId = targetId.split(":")[0];
46
- console.log("[buz outbound] removed sender suffix, final targetId:", targetId);
47
17
  }
48
18
  } else if (targetId.startsWith("user:")) {
49
19
  targetId = targetId.substring("user:".length);
50
- console.log("[buz outbound] removed 'user:' prefix, targetId:", targetId);
51
20
  }
52
21
 
22
+ return { chatType, targetId };
23
+ }
24
+
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
+ }
40
+
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
+ }
48
+
49
+ export async function sendText(params: any) {
50
+ const { to, text, accountId, replyToId, type = "final_reply", event } = params;
51
+
52
+ console.log("[buz outbound] sendText called:", JSON.stringify({ to, accountId, type, textLength: text?.length }));
53
+
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);
58
+
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
+ );
69
+
53
70
  const outboundMsg = {
54
71
  outbound_msg: {
55
72
  reply_to_id: replyToId || "",
56
- target_id: targetId,
57
- chat_type: chatType,
58
- content_text: text,
73
+ target_id: targetId || "",
74
+ chat_type: hasExplicitTarget ? chatType : "",
75
+ content_text: text || "",
76
+ type,
77
+ event: event || "",
59
78
  },
60
79
  };
61
80
 
62
81
  console.log("[buz outbound] outboundMsg:", JSON.stringify(outboundMsg, null, 2));
63
82
 
64
- try {
65
- stream.write(outboundMsg);
66
- console.log("[buz outbound] gRPC stream write successful");
67
- } catch (err: any) {
68
- console.error("[buz outbound] ERROR writing to stream:", err.message);
69
- 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
+ }
70
98
  }
71
99
 
72
- const result = { messageId: `msg-${Date.now()}`, chatId: to };
73
- return result;
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
+ }