@wecode-ai/weibo-openclaw-plugin 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/bot.ts ADDED
@@ -0,0 +1,486 @@
1
+ import { createHash } from "node:crypto";
2
+ import type { ClawdbotConfig, RuntimeEnv } from "openclaw/plugin-sdk";
3
+ import type {
4
+ WeiboInboundAttachmentPart,
5
+ WeiboMessageContext,
6
+ WeiboResponseContentPart,
7
+ WeiboResponseMessageInputItem,
8
+ } from "./types.js";
9
+ import { resolveWeiboAccount } from "./accounts.js";
10
+ import { createWeiboOutboundStream } from "./outbound-stream.js";
11
+ import { getWeiboRuntime } from "./runtime.js";
12
+ import { buildAgentMediaPayloadCompat } from "./plugin-sdk-compat.js";
13
+
14
+ // Simple in-memory dedup
15
+ const processedMessages = new Set<string>();
16
+ const MAX_INBOUND_IMAGE_BYTES = 10 * 1024 * 1024;
17
+ const MAX_INBOUND_FILE_BYTES = 5 * 1024 * 1024;
18
+ const SUPPORTED_IMAGE_MIME_TYPES = new Set([
19
+ "image/jpeg",
20
+ "image/png",
21
+ "image/gif",
22
+ "image/webp",
23
+ ]);
24
+
25
+ function isDuplicate(messageId: string): boolean {
26
+ if (processedMessages.has(messageId)) {
27
+ return true;
28
+ }
29
+ processedMessages.add(messageId);
30
+ // Cleanup old entries periodically
31
+ if (processedMessages.size > 1000) {
32
+ const toDelete = Array.from(processedMessages).slice(0, 500);
33
+ toDelete.forEach((id) => processedMessages.delete(id));
34
+ }
35
+ return false;
36
+ }
37
+
38
+ function resolveInboundMessageId(event: WeiboMessageEvent): string {
39
+ const explicitMessageId = event.payload.messageId.trim();
40
+ if (explicitMessageId) {
41
+ return explicitMessageId;
42
+ }
43
+
44
+ const digest = createHash("sha1")
45
+ .update(JSON.stringify({
46
+ fromUserId: event.payload.fromUserId,
47
+ text: event.payload.text ?? "",
48
+ timestamp: event.payload.timestamp ?? null,
49
+ input: event.payload.input ?? [],
50
+ }))
51
+ .digest("hex")
52
+ .slice(0, 16);
53
+
54
+ return `weibo_inbound_${digest}`;
55
+ }
56
+
57
+ export type WeiboMessageEvent = {
58
+ type: "message";
59
+ payload: {
60
+ messageId: string;
61
+ fromUserId: string;
62
+ text?: string;
63
+ timestamp?: number;
64
+ input?: WeiboResponseMessageInputItem[];
65
+ };
66
+ };
67
+
68
+ export type NormalizedWeiboInboundInput = {
69
+ text: string;
70
+ images: WeiboInboundAttachmentPart[];
71
+ files: WeiboInboundAttachmentPart[];
72
+ };
73
+
74
+ function isSupportedWeiboContentPart(part: unknown): part is WeiboResponseContentPart {
75
+ if (!part || typeof part !== "object") {
76
+ return false;
77
+ }
78
+
79
+ const type = (part as { type?: unknown }).type;
80
+ return type === "input_text" || type === "input_image" || type === "input_file";
81
+ }
82
+
83
+ export function normalizeWeiboInboundInput(event: WeiboMessageEvent): NormalizedWeiboInboundInput {
84
+ const textParts: string[] = [];
85
+ const images: WeiboInboundAttachmentPart[] = [];
86
+ const files: WeiboInboundAttachmentPart[] = [];
87
+
88
+ for (const item of event.payload.input ?? []) {
89
+ if (item.type !== "message" || item.role !== "user" || !Array.isArray(item.content)) {
90
+ continue;
91
+ }
92
+
93
+ for (const part of item.content) {
94
+ if (!isSupportedWeiboContentPart(part)) {
95
+ continue;
96
+ }
97
+
98
+ if (part.type === "input_text") {
99
+ if (typeof part.text === "string" && part.text) {
100
+ textParts.push(part.text);
101
+ }
102
+ continue;
103
+ }
104
+
105
+ const target = part.type === "input_image" ? images : files;
106
+ target.push({
107
+ mimeType: part.source.media_type,
108
+ filename: part.filename,
109
+ base64: part.source.data,
110
+ });
111
+ }
112
+ }
113
+
114
+ const normalizedText = textParts.length > 0
115
+ ? textParts.join("\n")
116
+ : (event.payload.text ?? "");
117
+
118
+ return {
119
+ text: normalizedText,
120
+ images,
121
+ files,
122
+ };
123
+ }
124
+
125
+ async function persistWeiboInboundAttachments(params: {
126
+ normalized: NormalizedWeiboInboundInput;
127
+ runtimeCore: ReturnType<typeof getWeiboRuntime>;
128
+ error: (message: string, ...args: unknown[]) => void;
129
+ }): Promise<ReturnType<typeof buildAgentMediaPayloadCompat>> {
130
+ const { normalized, runtimeCore, error } = params;
131
+ const mediaList: Array<{ path: string; contentType?: string | null }> = [];
132
+
133
+ for (const image of normalized.images) {
134
+ if (!SUPPORTED_IMAGE_MIME_TYPES.has(image.mimeType)) {
135
+ error(`weibo: unsupported image mime type: ${image.mimeType}`);
136
+ continue;
137
+ }
138
+
139
+ try {
140
+ const buffer = Buffer.from(image.base64, "base64");
141
+ if (buffer.length === 0) {
142
+ error(`weibo: empty image payload: ${image.filename ?? "unknown"}`);
143
+ continue;
144
+ }
145
+
146
+ const saved = await runtimeCore.channel.media.saveMediaBuffer(
147
+ buffer,
148
+ image.mimeType,
149
+ "inbound",
150
+ MAX_INBOUND_IMAGE_BYTES,
151
+ image.filename,
152
+ );
153
+
154
+ mediaList.push({
155
+ path: saved.path,
156
+ contentType: saved.contentType,
157
+ });
158
+ } catch (err) {
159
+ error(`weibo: failed to persist image input: ${String(err)}`);
160
+ }
161
+ }
162
+
163
+ for (const file of normalized.files) {
164
+ try {
165
+ const buffer = Buffer.from(file.base64, "base64");
166
+ if (buffer.length === 0) {
167
+ error(`weibo: empty file payload: ${file.filename ?? "unknown"}`);
168
+ continue;
169
+ }
170
+
171
+ const saved = await runtimeCore.channel.media.saveMediaBuffer(
172
+ buffer,
173
+ file.mimeType,
174
+ "inbound",
175
+ MAX_INBOUND_FILE_BYTES,
176
+ file.filename,
177
+ );
178
+
179
+ mediaList.push({
180
+ path: saved.path,
181
+ contentType: saved.contentType,
182
+ });
183
+ } catch (err) {
184
+ error(`weibo: failed to persist file input: ${String(err)}`);
185
+ }
186
+ }
187
+
188
+ return buildAgentMediaPayloadCompat(mediaList);
189
+ }
190
+
191
+ export type HandleWeiboMessageParams = {
192
+ cfg: ClawdbotConfig;
193
+ event: WeiboMessageEvent;
194
+ accountId: string;
195
+ runtime?: RuntimeEnv;
196
+ };
197
+
198
+ export async function handleWeiboMessage(params: HandleWeiboMessageParams): Promise<WeiboMessageContext | null> {
199
+ const { cfg, event, accountId, runtime } = params;
200
+ const log = runtime?.log ?? console.log;
201
+ const error = runtime?.error ?? console.error;
202
+
203
+ const account = resolveWeiboAccount({ cfg, accountId });
204
+ if (!account.enabled || !account.configured) {
205
+ error(`weibo[${accountId}]: account not enabled or configured`);
206
+ return null;
207
+ }
208
+
209
+ const { fromUserId, timestamp } = event.payload;
210
+ const messageId = resolveInboundMessageId(event);
211
+
212
+ // Deduplication
213
+ if (isDuplicate(messageId)) {
214
+ return null;
215
+ }
216
+ const inboundAcceptedAt = Date.now();
217
+ const streamDebugEnabled = process.env.WEIBO_STREAM_DEBUG === "1";
218
+ const streamDebug = (tag: string, data?: Record<string, unknown>): void => {
219
+ if (!streamDebugEnabled) {
220
+ return;
221
+ }
222
+ const payload = data ? ` ${JSON.stringify(data)}` : "";
223
+ log(`weibo[${accountId}][stream-debug] ${tag}${payload}`);
224
+ };
225
+
226
+ // Get runtime core
227
+ const core = getWeiboRuntime();
228
+
229
+ // Build message content
230
+ const normalized = normalizeWeiboInboundInput(event);
231
+ const content = normalized.text;
232
+ const hasText = content.trim().length > 0;
233
+ const hasAttachments = normalized.images.length > 0 || normalized.files.length > 0;
234
+ if (!hasText && !hasAttachments) {
235
+ return null;
236
+ }
237
+ const mediaPayload = await persistWeiboInboundAttachments({
238
+ normalized,
239
+ runtimeCore: core,
240
+ error,
241
+ });
242
+ const hasPersistedMedia = Array.isArray(mediaPayload.MediaPaths) && mediaPayload.MediaPaths.length > 0;
243
+ if (!hasText && !hasPersistedMedia) {
244
+ return null;
245
+ }
246
+
247
+ // Resolve routing - find which agent should handle this message
248
+ const route = core.channel.routing.resolveAgentRoute({
249
+ cfg,
250
+ channel: "weibo",
251
+ accountId,
252
+ peer: {
253
+ kind: "direct",
254
+ id: fromUserId,
255
+ },
256
+ });
257
+
258
+ if (!route.agentId) {
259
+ log(`weibo[${accountId}]: no agent route found for ${fromUserId}`);
260
+ return null;
261
+ }
262
+
263
+ log(`weibo[${accountId}]: received message from ${fromUserId}, routing to ${route.agentId} (session=${route.sessionKey})`);
264
+
265
+ // Enqueue system event for logging/monitoring
266
+ const preview = content.replace(/\s+/g, " ").slice(0, 160);
267
+ core.system.enqueueSystemEvent(`Weibo[${accountId}] DM from ${fromUserId}: ${preview}`, {
268
+ sessionKey: route.sessionKey,
269
+ contextKey: `weibo:message:${fromUserId}:${messageId}`,
270
+ });
271
+
272
+ // Build the inbound envelope (message body for agent)
273
+ const body = core.channel.reply.formatInboundEnvelope({
274
+ channel: "Weibo",
275
+ from: fromUserId,
276
+ body: content,
277
+ timestamp: timestamp ?? Date.now(),
278
+ sender: { name: fromUserId, id: fromUserId },
279
+ });
280
+
281
+ // Resolve text chunking settings
282
+ const textChunkLimit = core.channel.text.resolveTextChunkLimit(cfg, "weibo", accountId, {
283
+ fallbackLimit: account.config.textChunkLimit ?? 4000,
284
+ });
285
+ const chunkMode = account.config.chunkMode
286
+ ?? core.channel.text.resolveChunkMode(cfg, "weibo", accountId);
287
+ // Weibo real-time streaming is driven by onPartialReply; disable block streaming to avoid duplicate lanes.
288
+ const disableBlockStreaming = true;
289
+ streamDebug("dispatch_init", {
290
+ inboundMessageId: messageId,
291
+ fromUserId,
292
+ chunkMode,
293
+ textChunkLimit,
294
+ configuredBlockStreaming: account.config.blockStreaming,
295
+ disableBlockStreaming,
296
+ });
297
+ let currentOutboundMessageId: string | null = null;
298
+ let currentOutboundChunkId = 0;
299
+ let hasLoggedFirstChunkLatency = false;
300
+
301
+ const ensureOutboundMessageId = async (): Promise<string> => {
302
+ if (currentOutboundMessageId) {
303
+ return currentOutboundMessageId;
304
+ }
305
+ const { generateWeiboMessageId } = await import("./send.js");
306
+ currentOutboundMessageId = generateWeiboMessageId();
307
+ currentOutboundChunkId = 0;
308
+ return currentOutboundMessageId;
309
+ };
310
+
311
+ const sendOutboundChunk = async (params: {
312
+ text: string;
313
+ done: boolean;
314
+ source: "partial" | "deliver" | "settled";
315
+ }): Promise<void> => {
316
+ const { sendMessageWeibo } = await import("./send.js");
317
+ const outboundMessageId = await ensureOutboundMessageId();
318
+ streamDebug("send_chunk", {
319
+ source: params.source,
320
+ messageId: outboundMessageId,
321
+ chunkId: currentOutboundChunkId,
322
+ done: params.done,
323
+ textLen: params.text.length,
324
+ preview: params.text.slice(0, 80),
325
+ });
326
+ await sendMessageWeibo({
327
+ cfg,
328
+ to: fromUserId,
329
+ text: params.text,
330
+ messageId: outboundMessageId,
331
+ chunkId: currentOutboundChunkId,
332
+ done: params.done,
333
+ });
334
+ if (!hasLoggedFirstChunkLatency && params.text.length > 0) {
335
+ const elapsedMs = Math.max(0, Date.now() - inboundAcceptedAt);
336
+ log(`weibo[${accountId}]: first chunk first-char latency=${elapsedMs}ms`);
337
+ hasLoggedFirstChunkLatency = true;
338
+ }
339
+ currentOutboundChunkId += 1;
340
+ };
341
+
342
+ const outboundStream = createWeiboOutboundStream({
343
+ chunkMode,
344
+ textChunkLimit,
345
+ emit: sendOutboundChunk,
346
+ chunkTextWithMode: (text, limit, mode) =>
347
+ core.channel.text.chunkTextWithMode(text, limit, mode),
348
+ streamDebug,
349
+ });
350
+
351
+ // Build final inbound context
352
+ const ctxPayload = core.channel.reply.finalizeInboundContext({
353
+ Body: body,
354
+ BodyForAgent: content,
355
+ BodyForCommands: content,
356
+ RawBody: content,
357
+ CommandBody: content,
358
+ From: `weibo:${fromUserId}`,
359
+ To: fromUserId,
360
+ SessionKey: route.sessionKey,
361
+ AccountId: route.accountId,
362
+ ChatType: "direct",
363
+ ConversationLabel: fromUserId,
364
+ SenderName: fromUserId,
365
+ SenderId: fromUserId,
366
+ Provider: "weibo" as const,
367
+ Surface: "weibo" as const,
368
+ MessageSid: messageId,
369
+ Timestamp: timestamp ?? Date.now(),
370
+ WasMentioned: true,
371
+ CommandAuthorized: true,
372
+ OriginatingChannel: "weibo" as const,
373
+ OriginatingTo: fromUserId,
374
+ ...mediaPayload,
375
+ });
376
+
377
+ // Create a dispatcher that sends replies back to Weibo
378
+ const { dispatcher, replyOptions, markDispatchIdle } = core.channel.reply.createReplyDispatcherWithTyping({
379
+ deliver: async (reply, info?: { kind?: string }) => {
380
+ const isFinalDeliver = info?.kind !== "block";
381
+ const before = outboundStream.snapshot();
382
+ streamDebug("deliver_enter", {
383
+ kind: info?.kind ?? "unknown",
384
+ isFinalDeliver,
385
+ textLen: (reply.text ?? "").length,
386
+ ...before,
387
+ });
388
+ await outboundStream.pushDeliverText({
389
+ text: reply.text ?? "",
390
+ isFinal: isFinalDeliver,
391
+ });
392
+ streamDebug("deliver_exit", {
393
+ kind: info?.kind ?? "unknown",
394
+ isFinalDeliver,
395
+ ...outboundStream.snapshot(),
396
+ });
397
+ },
398
+ onError: (err, info) => {
399
+ error(`weibo[${accountId}] ${info.kind} reply failed: ${String(err)}`);
400
+ },
401
+ onIdle: () => {
402
+ log(`weibo[${accountId}]: reply dispatcher idle`);
403
+ },
404
+ });
405
+
406
+ // Dispatch to agent
407
+ log(`weibo[${accountId}]: dispatching to agent (session=${route.sessionKey})`);
408
+
409
+ try {
410
+ const onSettled = async () => {
411
+ streamDebug("dispatcher_settled_before", {
412
+ currentOutboundMessageId,
413
+ currentOutboundChunkId,
414
+ ...outboundStream.snapshot(),
415
+ });
416
+ await outboundStream.settle();
417
+ streamDebug("dispatcher_settled_after", {
418
+ currentOutboundMessageId,
419
+ currentOutboundChunkId,
420
+ ...outboundStream.snapshot(),
421
+ });
422
+ currentOutboundMessageId = null;
423
+ currentOutboundChunkId = 0;
424
+ markDispatchIdle();
425
+ };
426
+
427
+ const runDispatch = () => core.channel.reply.dispatchReplyFromConfig({
428
+ ctx: ctxPayload,
429
+ cfg,
430
+ dispatcher,
431
+ replyOptions: {
432
+ ...replyOptions,
433
+ disableBlockStreaming,
434
+ onPartialReply: async (payload) => {
435
+ streamDebug("on_partial_reply", {
436
+ textLen: (payload.text ?? "").length,
437
+ preview: (payload.text ?? "").slice(0, 80),
438
+ });
439
+ await outboundStream.pushPartialSnapshot(payload.text ?? "");
440
+ },
441
+ onAssistantMessageStart: () => {
442
+ streamDebug("on_assistant_message_start");
443
+ },
444
+ onReasoningEnd: () => {
445
+ streamDebug("on_reasoning_end");
446
+ },
447
+ },
448
+ });
449
+
450
+ const withReplyDispatcher = (core.channel.reply as {
451
+ withReplyDispatcher?: (params: {
452
+ dispatcher: unknown;
453
+ run: () => Promise<{ queuedFinal: boolean; counts: { final: number } }>;
454
+ onSettled?: () => Promise<void> | void;
455
+ }) => Promise<{ queuedFinal: boolean; counts: { final: number } }>;
456
+ }).withReplyDispatcher;
457
+
458
+ const result = typeof withReplyDispatcher === "function"
459
+ ? await withReplyDispatcher({
460
+ dispatcher,
461
+ onSettled,
462
+ run: runDispatch,
463
+ })
464
+ : await (async () => {
465
+ try {
466
+ return await runDispatch();
467
+ } finally {
468
+ await onSettled();
469
+ }
470
+ })();
471
+
472
+ log(`weibo[${accountId}]: dispatch complete (queuedFinal=${result.queuedFinal}, replies=${result.counts.final})`);
473
+ } catch (err) {
474
+ error(`weibo[${accountId}]: failed to dispatch message: ${String(err)}`);
475
+ }
476
+
477
+ // Build and return message context
478
+ const messageContext: WeiboMessageContext = {
479
+ messageId,
480
+ senderId: fromUserId,
481
+ text: content,
482
+ createTime: timestamp,
483
+ };
484
+
485
+ return messageContext;
486
+ }