@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 +43 -4
- package/openclaw.plugin.json +19 -1
- package/package.json +1 -1
- package/proto/buz.proto +15 -17
- package/src/gateway.ts +139 -26
- package/src/inbound.ts +134 -11
- package/src/outbound.ts +77 -48
- package/src/session-route.ts +36 -0
- package/src/targets.ts +46 -0
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:
|
|
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:
|
|
115
|
+
looksLikeId: looksLikeBuzId,
|
|
96
116
|
hint: "<targetId>",
|
|
97
117
|
},
|
|
98
|
-
resolveSessionTarget: ({ id }: any) =>
|
|
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 });
|
package/openclaw.plugin.json
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"id": "buz",
|
|
3
|
-
"channels": [
|
|
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
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;
|
|
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;
|
|
37
|
+
string account_id = 3;
|
|
40
38
|
}
|
|
41
39
|
|
|
42
40
|
message InboundMessage {
|
|
43
|
-
string message_id = 1;
|
|
44
|
-
string sender_id = 2;
|
|
45
|
-
string sender_name = 3;
|
|
46
|
-
string chat_type = 4;
|
|
47
|
-
string group_id = 5;
|
|
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;
|
|
54
|
-
string target_id = 2;
|
|
55
|
-
string chat_type = 3;
|
|
56
|
-
string content_text = 4;
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
|
107
|
-
console.log(`[buz gateway] reconnecting in ${delay}ms (attempt ${
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
267
|
+
scheduleReconnect(err?.message || "failed to send auth request");
|
|
268
|
+
return;
|
|
172
269
|
}
|
|
173
270
|
|
|
174
271
|
stream.on("data", async (msg: any) => {
|
|
175
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
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
|
-
|
|
73
|
-
|
|
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
|
+
}
|