openclaw-channel-dmwork 0.6.1 → 0.6.2-dev.84da5e15

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.
@@ -1,4 +1,4 @@
1
- import { sendMessage, editMessage, sendReadReceipt, sendTyping, getChannelMessages, getGroupMembers, getGroupMd, sendMediaMessage, inferContentType, ensureTextCharset, parseImageDimensionsFromFile, getUploadCredentials, uploadFileToCOS, fetchUserInfo } from "./api-fetch.js";
1
+ import { sendMessage, sendReadReceipt, sendTyping, getChannelMessages, getGroupMembers, getGroupMd, sendMediaMessage, inferContentType, ensureTextCharset, parseImageDimensionsFromFile, getUploadCredentials, uploadFileToCOS, fetchUserInfo } from "./api-fetch.js";
2
2
  import { ChannelType, MessageType } from "./types.js";
3
3
  import { getDmworkRuntime } from "./runtime.js";
4
4
  import { DEFAULT_HISTORY_PROMPT_TEMPLATE } from "./config-schema.js";
@@ -8,6 +8,9 @@ import { createWriteStream } from "node:fs";
8
8
  import { mkdir, unlink, readdir, stat } from "node:fs/promises";
9
9
  import { join, basename } from "node:path";
10
10
  import { randomUUID } from "node:crypto";
11
+ // Pending inbound context for before_prompt_build hook injection.
12
+ // handleInboundMessage writes here; the hook reads and clears per sessionKey.
13
+ export const pendingInboundContext = new Map();
11
14
  // Defensive imports — these may not exist in older OpenClaw versions
12
15
  // History context managed manually for cross-SDK compatibility
13
16
  let clearHistoryEntriesIfEnabled;
@@ -1203,11 +1206,13 @@ export async function handleInboundMessage(params) {
1203
1206
  storePath,
1204
1207
  sessionKey: route.sessionKey,
1205
1208
  });
1206
- // Inject member list for group messages to help LLM learn @[uid:name] format
1209
+ // memberListPrefix and historyPrefix are injected via before_prompt_build hook
1210
+ // (not persisted to session history). Only quotePrefix stays in Body.
1207
1211
  const memberListPrefix = isGroup ? buildMemberListPrefix(uidToNameMap) : "";
1208
- const finalBody = (memberListPrefix || historyPrefix || quotePrefix)
1209
- ? (memberListPrefix + historyPrefix + quotePrefix + rawBody)
1210
- : rawBody;
1212
+ if (historyPrefix || memberListPrefix) {
1213
+ pendingInboundContext.set(route.sessionKey, { historyPrefix, memberListPrefix });
1214
+ }
1215
+ const finalBody = quotePrefix ? (quotePrefix + rawBody) : rawBody;
1211
1216
  const body = core.channel.reply.formatAgentEnvelope({
1212
1217
  channel: "DMWork",
1213
1218
  from: fromLabel,
@@ -1216,10 +1221,8 @@ export async function handleInboundMessage(params) {
1216
1221
  envelope: envelopeOptions,
1217
1222
  body: finalBody,
1218
1223
  });
1219
- // Inject GROUP.md as GroupSystemPrompt for group messages
1220
- const groupSystemPrompt = isGroup && groupMdCache && message.channel_id
1221
- ? groupMdCache.get(extractParentGroupNo(message.channel_id))?.content
1222
- : undefined;
1224
+ // GROUP.md injection is handled exclusively by the before_prompt_build hook
1225
+ // (see index.ts getGroupMdForPrompt) no longer set here to avoid duplication.
1223
1226
  // Resolve sender display name — async fallback for DM users not in cache
1224
1227
  let senderName = resolveSenderName(message.from_uid, uidToNameMap);
1225
1228
  if (!senderName && !isGroup) {
@@ -1245,9 +1248,10 @@ export async function handleInboundMessage(params) {
1245
1248
  }
1246
1249
  const ctxPayload = core.channel.reply.finalizeInboundContext({
1247
1250
  Body: body,
1248
- BodyForAgent: body, // ← 关键!AI 实际读取的是这个字段!
1251
+ BodyForAgent: body,
1249
1252
  RawBody: rawBody,
1250
1253
  CommandBody: rawBody,
1254
+ CommandAuthorized: true,
1251
1255
  MediaUrl: isFileMessage ? undefined : inboundMediaUrl,
1252
1256
  MediaUrls: (() => {
1253
1257
  // Only pass current message's local media path (no remote history URLs)
@@ -1268,7 +1272,7 @@ export async function handleInboundMessage(params) {
1268
1272
  MessageSid: String(message.message_id),
1269
1273
  Timestamp: message.timestamp ? message.timestamp * 1000 : undefined,
1270
1274
  GroupSubject: isGroup ? message.channel_id : undefined,
1271
- GroupSystemPrompt: groupSystemPrompt,
1275
+ GroupSystemPrompt: undefined,
1272
1276
  Provider: "dmwork",
1273
1277
  Surface: "dmwork",
1274
1278
  OriginatingChannel: "dmwork",
@@ -1300,21 +1304,126 @@ export async function handleInboundMessage(params) {
1300
1304
  const typingInterval = setInterval(() => {
1301
1305
  sendTyping({ apiUrl, botToken, channelId: replyChannelId, channelType: replyChannelType }).catch(() => { });
1302
1306
  }, 5000);
1303
- // Draft-edit dedup: tracks the first sent message so subsequent delivers edit it.
1304
- // draftMessage is scoped to this handleInboundMessage invocation; deliver is called
1305
- // sequentially by dispatchReplyWithBufferedBlockDispatcher, so no concurrency issue.
1306
- let draftMessage = null;
1307
+ // Buffer text across streaming deliver calls; only send once after dispatcher finishes.
1308
+ // Media is sent immediately (no edit problem); text is buffered (each call overwrites).
1309
+ const deliverBuffer = {
1310
+ lastText: null,
1311
+ textSent: false,
1312
+ };
1313
+ const sentMediaUrls = new Set();
1314
+ // --- Shared helper: resolve mentions and send text ---
1315
+ const resolveAndSendText = async (content) => {
1316
+ let replyMentionUids = [];
1317
+ let replyMentionEntities = [];
1318
+ let finalContent = content;
1319
+ if (isGroup) {
1320
+ const structuredMentions = parseStructuredMentions(content);
1321
+ if (structuredMentions.length > 0) {
1322
+ // v2 path: LLM used @[uid:name] format
1323
+ const validUids = new Set(uidToNameMap.keys());
1324
+ const converted = convertStructuredMentions(content, structuredMentions, validUids);
1325
+ finalContent = converted.content;
1326
+ replyMentionEntities = [...converted.entities];
1327
+ // Mixed scenario: check for remaining @name in converted content
1328
+ const remaining = buildEntitiesFromFallback(finalContent, memberMap);
1329
+ const existingOffsets = new Set(replyMentionEntities.map((e) => e.offset));
1330
+ for (const rm of remaining.entities) {
1331
+ if (!existingOffsets.has(rm.offset)) {
1332
+ replyMentionEntities.push(rm);
1333
+ }
1334
+ }
1335
+ log?.debug?.(`dmwork: [REPLY] structured mentions: ${structuredMentions.length}, fallback: ${remaining.entities.length}`);
1336
+ }
1337
+ else {
1338
+ // v1 fallback path: LLM used @name format
1339
+ const contentMentions = extractMentionMatches(content);
1340
+ const unresolvedNames = [];
1341
+ const resolveMention = (name) => {
1342
+ const uid = findUidByName(name, memberMap);
1343
+ let newContent = finalContent;
1344
+ if (uid) {
1345
+ return { uid, newContent };
1346
+ }
1347
+ else if (/^[a-f0-9]{32}$/i.test(name)) {
1348
+ const displayName = uidToNameMap.get(name);
1349
+ if (displayName) {
1350
+ newContent = newContent.replace(`@${name}`, `@${displayName}`);
1351
+ return { uid: name, newContent };
1352
+ }
1353
+ return { uid: name, newContent };
1354
+ }
1355
+ else if (/^[a-zA-Z0-9_]+$/.test(name)) {
1356
+ const displayName = uidToNameMap.get(name);
1357
+ if (displayName) {
1358
+ newContent = newContent.replace(`@${name}`, `@${displayName}`);
1359
+ return { uid: name, newContent };
1360
+ }
1361
+ return { uid: name, newContent };
1362
+ }
1363
+ return { uid: null, newContent };
1364
+ };
1365
+ const resolvedUids = [];
1366
+ for (const mention of contentMentions) {
1367
+ const name = mention.slice(1);
1368
+ const result = resolveMention(name);
1369
+ finalContent = result.newContent;
1370
+ resolvedUids.push(result.uid);
1371
+ if (!result.uid) {
1372
+ unresolvedNames.push({ name, index: resolvedUids.length - 1 });
1373
+ }
1374
+ }
1375
+ if (unresolvedNames.length > 0) {
1376
+ log?.info?.(`dmwork: [REPLY] ${unresolvedNames.length} unresolved names, force refreshing cache...`);
1377
+ const refreshed = await refreshGroupMemberCache({ sessionId: memberCacheGroupNo, memberMap, uidToNameMap, groupCacheTimestamps, apiUrl: account.config.apiUrl, botToken: account.config.botToken ?? "", forceRefresh: true, log });
1378
+ if (refreshed) {
1379
+ for (const { name, index } of unresolvedNames) {
1380
+ const uid = findUidByName(name, memberMap);
1381
+ if (uid) {
1382
+ resolvedUids[index] = uid;
1383
+ }
1384
+ }
1385
+ }
1386
+ }
1387
+ replyMentionUids = resolvedUids.filter((uid) => uid !== null);
1388
+ const fallbackResult = buildEntitiesFromFallback(finalContent, memberMap);
1389
+ replyMentionEntities = fallbackResult.entities;
1390
+ }
1391
+ // Sort entities by offset and rebuild uids from sorted entities
1392
+ if (replyMentionEntities.length > 0) {
1393
+ replyMentionEntities.sort((a, b) => a.offset - b.offset);
1394
+ replyMentionUids = replyMentionEntities.map((e) => e.uid);
1395
+ }
1396
+ }
1397
+ // Detect @all/@所有人 in final content
1398
+ const hasAtAll = /(?:^|(?<=\s))@(?:all|所有人)(?=\s|[^\w]|$)/i.test(finalContent);
1399
+ await sendMessage({
1400
+ apiUrl: account.config.apiUrl,
1401
+ botToken: account.config.botToken ?? "",
1402
+ channelId: replyChannelId,
1403
+ channelType: replyChannelType,
1404
+ content: finalContent,
1405
+ ...(replyMentionUids.length > 0 ? { mentionUids: replyMentionUids } : {}),
1406
+ ...(replyMentionEntities.length > 0 ? { mentionEntities: replyMentionEntities } : {}),
1407
+ mentionAll: hasAtAll || undefined,
1408
+ });
1409
+ statusSink?.({ lastOutboundAt: Date.now(), lastError: null });
1410
+ };
1307
1411
  try {
1308
1412
  await core.channel.reply.dispatchReplyWithBufferedBlockDispatcher({
1309
1413
  ctx: ctxPayload,
1310
1414
  cfg: config,
1311
1415
  replyOptions: {},
1312
1416
  dispatcherOptions: {
1313
- deliver: async (payload) => {
1314
- // Resolve outbound media URLs
1417
+ deliver: async (payload, info) => {
1418
+ // Skip reasoning blocks
1419
+ if (payload.isReasoning)
1420
+ return;
1421
+ const kind = info?.kind ?? "final";
1422
+ // --- Media: send immediately (no edit/forward issue) with dedup ---
1315
1423
  const outboundMediaUrls = resolveOutboundMediaUrls(payload);
1316
- // Upload and send each media file
1317
1424
  for (const mediaUrl of outboundMediaUrls) {
1425
+ if (sentMediaUrls.has(mediaUrl))
1426
+ continue;
1318
1427
  try {
1319
1428
  await uploadAndSendMedia({
1320
1429
  mediaUrl,
@@ -1324,12 +1433,13 @@ export async function handleInboundMessage(params) {
1324
1433
  channelType: replyChannelType,
1325
1434
  log,
1326
1435
  });
1436
+ sentMediaUrls.add(mediaUrl);
1327
1437
  }
1328
1438
  catch (err) {
1329
1439
  log?.error?.(`dmwork: media send failed for ${mediaUrl}: ${String(err)}`);
1330
1440
  }
1331
1441
  }
1332
- // If there are no media URLs, fall through to text logic; if there are, only send text if caption exists
1442
+ // --- Text handling based on kind ---
1333
1443
  const content = payload.text?.trim() ?? "";
1334
1444
  if (!content && outboundMediaUrls.length > 0) {
1335
1445
  statusSink?.({ lastOutboundAt: Date.now(), lastError: null });
@@ -1337,146 +1447,22 @@ export async function handleInboundMessage(params) {
1337
1447
  }
1338
1448
  if (!content)
1339
1449
  return;
1340
- // Build mentionUids + entities from @mentions in content
1341
- // Supports both @[uid:name] (v2 structured) and @name (v1 fallback)
1342
- let replyMentionUids = [];
1343
- let replyMentionEntities = [];
1344
- let finalContent = content;
1345
- if (isGroup) {
1346
- const structuredMentions = parseStructuredMentions(content);
1347
- if (structuredMentions.length > 0) {
1348
- // v2 path: LLM used @[uid:name] format
1349
- const validUids = new Set(uidToNameMap.keys());
1350
- const converted = convertStructuredMentions(content, structuredMentions, validUids);
1351
- finalContent = converted.content;
1352
- replyMentionEntities = [...converted.entities];
1353
- // Mixed scenario: check for remaining @name in converted content
1354
- const remaining = buildEntitiesFromFallback(finalContent, memberMap);
1355
- const existingOffsets = new Set(replyMentionEntities.map((e) => e.offset));
1356
- for (const rm of remaining.entities) {
1357
- if (!existingOffsets.has(rm.offset)) {
1358
- replyMentionEntities.push(rm);
1359
- }
1360
- }
1361
- log?.debug?.(`dmwork: [REPLY] structured mentions: ${structuredMentions.length}, fallback: ${remaining.entities.length}`);
1362
- }
1363
- else {
1364
- // v1 fallback path: LLM used @name format
1365
- // Keep existing resolveMention logic for hex uid / uid-format handling
1366
- const contentMentions = extractMentionMatches(content);
1367
- let unresolvedNames = [];
1368
- const resolveMention = (name) => {
1369
- let uid = findUidByName(name, memberMap);
1370
- let newContent = finalContent;
1371
- if (uid) {
1372
- return { uid, newContent };
1373
- }
1374
- else if (/^[a-f0-9]{32}$/i.test(name)) {
1375
- const displayName = uidToNameMap.get(name);
1376
- if (displayName) {
1377
- newContent = newContent.replace(`@${name}`, `@${displayName}`);
1378
- return { uid: name, newContent };
1379
- }
1380
- return { uid: name, newContent };
1381
- }
1382
- else if (/^[a-zA-Z0-9_]+$/.test(name)) {
1383
- const displayName = uidToNameMap.get(name);
1384
- if (displayName) {
1385
- newContent = newContent.replace(`@${name}`, `@${displayName}`);
1386
- return { uid: name, newContent };
1387
- }
1388
- return { uid: name, newContent };
1389
- }
1390
- return { uid: null, newContent };
1391
- };
1392
- const resolvedUids = [];
1393
- for (const mention of contentMentions) {
1394
- const name = mention.slice(1);
1395
- const result = resolveMention(name);
1396
- finalContent = result.newContent;
1397
- resolvedUids.push(result.uid);
1398
- if (!result.uid) {
1399
- unresolvedNames.push({ name, index: resolvedUids.length - 1 });
1400
- }
1401
- }
1402
- if (unresolvedNames.length > 0) {
1403
- log?.info?.(`dmwork: [REPLY] ${unresolvedNames.length} unresolved names, force refreshing cache...`);
1404
- const refreshed = await refreshGroupMemberCache({ sessionId: memberCacheGroupNo, memberMap, uidToNameMap, groupCacheTimestamps, apiUrl: account.config.apiUrl, botToken: account.config.botToken ?? "", forceRefresh: true, log });
1405
- if (refreshed) {
1406
- for (const { name, index } of unresolvedNames) {
1407
- const uid = findUidByName(name, memberMap);
1408
- if (uid) {
1409
- resolvedUids[index] = uid;
1410
- }
1411
- }
1412
- }
1413
- }
1414
- replyMentionUids = resolvedUids.filter((uid) => uid !== null);
1415
- // Build entities from fallback for the final content
1416
- const fallbackResult = buildEntitiesFromFallback(finalContent, memberMap);
1417
- replyMentionEntities = fallbackResult.entities;
1418
- }
1419
- // Sort entities by offset and rebuild uids from sorted entities
1420
- if (replyMentionEntities.length > 0) {
1421
- replyMentionEntities.sort((a, b) => a.offset - b.offset);
1422
- replyMentionUids = replyMentionEntities.map((e) => e.uid);
1423
- }
1424
- }
1425
- // Detect @all/@所有人 in final content
1426
- const hasAtAll = /(?:^|(?<=\s))@(?:all|所有人)(?=\s|[^\w]|$)/i.test(finalContent);
1427
- // Draft-edit dedup: subsequent delivers edit the first message.
1428
- // Note: editMessage API only updates content_edit text; mention metadata
1429
- // (mentionUids/mentionEntities/mentionAll) cannot be updated via edit.
1430
- // This is acceptable because the first sendMessage already carries the
1431
- // correct mention info, and subsequent edits only update the text body.
1432
- if (draftMessage) {
1433
- try {
1434
- const contentEdit = JSON.stringify({ type: 1, content: finalContent });
1435
- log?.debug?.(`dmwork: [draft-edit] editMessage attempt id=${draftMessage.messageId} seq=${draftMessage.messageSeq}`);
1436
- await editMessage({
1437
- apiUrl: account.config.apiUrl,
1438
- botToken: account.config.botToken ?? "",
1439
- messageId: draftMessage.messageId,
1440
- messageSeq: draftMessage.messageSeq,
1441
- channelId: replyChannelId,
1442
- channelType: replyChannelType,
1443
- contentEdit,
1444
- });
1445
- log?.debug?.(`dmwork: [draft-edit] editMessage success`);
1446
- statusSink?.({ lastOutboundAt: Date.now(), lastError: null });
1447
- return;
1448
- }
1449
- catch (editErr) {
1450
- log?.warn?.(`dmwork: [draft-edit] editMessage failed: ${String(editErr)}, falling back to sendMessage`);
1451
- draftMessage = null;
1452
- }
1453
- }
1454
- const sendResult = await sendMessage({
1455
- apiUrl: account.config.apiUrl,
1456
- botToken: account.config.botToken ?? "",
1457
- channelId: replyChannelId,
1458
- channelType: replyChannelType,
1459
- content: finalContent,
1460
- ...(replyMentionUids.length > 0 ? { mentionUids: replyMentionUids } : {}),
1461
- ...(replyMentionEntities.length > 0 ? { mentionEntities: replyMentionEntities } : {}),
1462
- mentionAll: hasAtAll || undefined,
1463
- });
1464
- // Save draft for subsequent edit-dedup
1465
- if (sendResult?.message_id != null && sendResult?.message_seq != null) {
1466
- draftMessage = {
1467
- messageId: String(sendResult.message_id),
1468
- messageSeq: sendResult.message_seq,
1469
- };
1470
- log?.debug?.(`dmwork: [draft-edit] sendMessage OK, draft set: id=${draftMessage.messageId} seq=${draftMessage.messageSeq}`);
1471
- }
1472
- else {
1473
- log?.debug?.(`dmwork: [draft-edit] sendMessage returned no id/seq, draft-edit disabled`);
1450
+ if (kind === "tool") {
1451
+ // Verbose tool call output: send immediately
1452
+ await resolveAndSendText(content);
1453
+ log?.info?.(`dmwork: [deliver] tool text sent (${content.length} chars)`);
1454
+ return;
1474
1455
  }
1475
- statusSink?.({ lastOutboundAt: Date.now(), lastError: null });
1456
+ // kind === "block" / "final" / anything else: buffer, send only once after dispatcher finishes
1457
+ deliverBuffer.lastText = content;
1458
+ log?.debug?.(`dmwork: [deliver-buffer] ${kind} text buffered (${content.length} chars)`);
1476
1459
  },
1477
1460
  onError: async (err, info) => {
1478
1461
  clearInterval(typingInterval);
1479
1462
  log?.error?.(`dmwork ${info.kind} reply failed: ${String(err)}`);
1463
+ // Prevent finally block from sending stale buffered text after error
1464
+ deliverBuffer.lastText = null;
1465
+ deliverBuffer.textSent = true;
1480
1466
  try {
1481
1467
  await sendMessage({
1482
1468
  apiUrl,
@@ -1494,7 +1480,20 @@ export async function handleInboundMessage(params) {
1494
1480
  });
1495
1481
  }
1496
1482
  finally {
1483
+ // --- Final send: deliver buffered text if only blocks arrived (no final/tool) ---
1484
+ if (deliverBuffer.lastText && !deliverBuffer.textSent) {
1485
+ deliverBuffer.textSent = true;
1486
+ try {
1487
+ await resolveAndSendText(deliverBuffer.lastText);
1488
+ log?.info?.(`dmwork: [deliver-buffer] fallback text sent (${deliverBuffer.lastText.length} chars)`);
1489
+ }
1490
+ catch (finalSendErr) {
1491
+ log?.error?.(`dmwork: [deliver-buffer] final text send failed: ${String(finalSendErr)}`);
1492
+ }
1493
+ }
1497
1494
  clearInterval(typingInterval);
1495
+ // Safety net: clean up pending inbound context in case the hook didn't fire
1496
+ pendingInboundContext.delete(route.sessionKey);
1498
1497
  }
1499
1498
  }
1500
1499
  //# sourceMappingURL=inbound.js.map