Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
80 changes: 80 additions & 0 deletions openclaw-channel-dmwork/src/api-fetch.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -794,3 +794,83 @@ describe("uploadFileToCOS putParams ContentType", () => {
expect(capturedParams.ContentType).toBe("text/plain; charset=utf-8");
});
});

// --- fetchUserInfo ---
import { fetchUserInfo } from "./api-fetch.js";

describe("fetchUserInfo", () => {
it("returns user info on success", async () => {
globalThis.fetch = vi.fn().mockResolvedValue({
ok: true,
status: 200,
json: () => Promise.resolve({ uid: "s14_abc", name: "Alice", avatar: "https://example.com/a.png" }),
}) as any;

const result = await fetchUserInfo({
apiUrl: "http://localhost:8090",
botToken: "tok",
uid: "s14_abc",
});
expect(result).toEqual({ uid: "s14_abc", name: "Alice", avatar: "https://example.com/a.png" });
expect(globalThis.fetch).toHaveBeenCalledWith(
"http://localhost:8090/v1/bot/user/info?uid=s14_abc",
expect.objectContaining({ method: "GET" }),
);
});

it("returns null on 404 (endpoint not implemented)", async () => {
globalThis.fetch = vi.fn().mockResolvedValue({
ok: false,
status: 404,
}) as any;

const result = await fetchUserInfo({
apiUrl: "http://localhost:8090",
botToken: "tok",
uid: "s14_abc",
});
expect(result).toBeNull();
});

it("returns null on 500 error", async () => {
globalThis.fetch = vi.fn().mockResolvedValue({
ok: false,
status: 500,
}) as any;

const result = await fetchUserInfo({
apiUrl: "http://localhost:8090",
botToken: "tok",
uid: "s14_abc",
log: { error: vi.fn() },
});
expect(result).toBeNull();
});

it("returns null on network error", async () => {
globalThis.fetch = vi.fn().mockRejectedValue(new Error("ECONNREFUSED")) as any;

const result = await fetchUserInfo({
apiUrl: "http://localhost:8090",
botToken: "tok",
uid: "s14_abc",
log: { error: vi.fn() },
});
expect(result).toBeNull();
});

it("returns null when response has no name", async () => {
globalThis.fetch = vi.fn().mockResolvedValue({
ok: true,
status: 200,
json: () => Promise.resolve({ uid: "s14_abc" }),
}) as any;

const result = await fetchUserInfo({
apiUrl: "http://localhost:8090",
botToken: "tok",
uid: "s14_abc",
});
expect(result).toBeNull();
});
});
37 changes: 37 additions & 0 deletions openclaw-channel-dmwork/src/api-fetch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -649,3 +649,40 @@ export async function editMessage(params: {
content_edit: params.contentEdit,
}, params.signal);
}

/**
* Fetch user info by UID. Requires backend `/v1/bot/user/info` endpoint.
* Returns null if the endpoint is unavailable (404) or returns an error,
* so callers can gracefully degrade.
*/
export async function fetchUserInfo(params: {
apiUrl: string;
botToken: string;
uid: string;
log?: { info?: (msg: string) => void; error?: (msg: string) => void };
}): Promise<{ uid: string; name: string; avatar?: string } | null> {
const url = `${params.apiUrl.replace(/\/+$/, "")}/v1/bot/user/info?uid=${encodeURIComponent(params.uid)}`;
try {
const resp = await fetch(url, {
method: "GET",
headers: { Authorization: `Bearer ${params.botToken}` },
signal: AbortSignal.timeout(5000),
});
if (resp.status === 404) {
// Endpoint not implemented yet — silent degrade
return null;
}
if (!resp.ok) {
params.log?.error?.(`dmwork: fetchUserInfo(${params.uid}) failed: ${resp.status}`);
return null;
}
const data = await resp.json() as { uid?: string; name?: string; avatar?: string };
if (data?.name) {
return { uid: data.uid ?? params.uid, name: data.name, avatar: data.avatar };
}
return null;
} catch (err) {
params.log?.error?.(`dmwork: fetchUserInfo(${params.uid}) error: ${String(err)}`);
return null;
}
}
35 changes: 29 additions & 6 deletions openclaw-channel-dmwork/src/channel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import {
resolveDmworkAccount,
type ResolvedDmworkAccount,
} from "./accounts.js";
import { registerBot, sendMessage, sendHeartbeat, sendMediaMessage, inferContentType, ensureTextCharset, fetchBotGroups, getGroupMd, parseImageDimensions, parseImageDimensionsFromFile, getUploadCredentials, uploadFileToCOS } from "./api-fetch.js";
import { registerBot, sendMessage, sendHeartbeat, sendMediaMessage, inferContentType, ensureTextCharset, fetchBotGroups, getGroupMd, getGroupMembers, parseImageDimensions, parseImageDimensionsFromFile, getUploadCredentials, uploadFileToCOS } from "./api-fetch.js";
import { WKSocket } from "./socket.js";
import { handleInboundMessage, type DmworkStatusSink } from "./inbound.js";
import { ChannelType, MessageType, type BotMessage, type MessagePayload } from "./types.js";
Expand Down Expand Up @@ -155,7 +155,8 @@ function cleanupStaleCaches(): void {
if (lastAccess < cutoff) {
_historyMaps.get(accountId)?.delete(groupId);
_memberMaps.get(accountId)?.delete(groupId);
_uidToNameMaps.get(accountId)?.delete(groupId);
// Note: uidToNameMap is a flat uid→name map (not keyed by groupId),
// so we don't delete from it here — names remain valid across groups.
_groupCacheTimestamps.get(accountId)?.delete(groupId);
activityMap.delete(groupId);
}
Expand Down Expand Up @@ -593,27 +594,49 @@ export const dmworkPlugin: ChannelPlugin<ResolvedDmworkAccount> = {
// Check for updates in background (fire-and-forget)
checkForUpdates(account.config.apiUrl, log).catch(() => {});

// Prefetch GROUP.md for all groups (fire-and-forget)
// Prefetch GROUP.md and group members for all groups (fire-and-forget)
const groupMdCache = getOrCreateGroupMdCache(account.accountId);
(async () => {
try {
const groups = await fetchBotGroups({ apiUrl: account.config.apiUrl, botToken: account.config.botToken!, log });
registerBotGroupIds(groups.map(g => g.group_no));
let mdCount = 0;
let memberCount = 0;
for (const g of groups) {
// Prefetch GROUP.md
try {
const md = await getGroupMd({ apiUrl: account.config.apiUrl, botToken: account.config.botToken!, groupNo: g.group_no, log });
if (md.content) {
groupMdCache.set(g.group_no, { content: md.content, version: md.version });
mdCount++;
}
} catch {
// Ignore per-group failures (group may not have GROUP.md)
}
// Prefetch group members → fill uidToNameMap for SenderName resolution
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice — prefetching group members on startup combined with the opportunistic fills from MultipleForward payloads and reply metadata in inbound.ts means the name cache will be well-populated by the time messages arrive. Solid pattern.

try {
const members = await getGroupMembers({ apiUrl: account.config.apiUrl, botToken: account.config.botToken!, groupNo: g.group_no, log });
const prefetchMemberMap = getOrCreateMemberMap(account.accountId);
const prefetchUidMap = getOrCreateUidToNameMap(account.accountId);
for (const m of members) {
if (m.uid && m.name) {
prefetchMemberMap.set(m.name, m.uid);
prefetchUidMap.set(m.uid, m.name);
memberCount++;
}
}
} catch {
// Ignore per-group failures
}
}
if (mdCount > 0) {
log?.info?.(`dmwork: prefetched GROUP.md for ${mdCount} groups`);
}
if (groupMdCache.size > 0) {
log?.info?.(`dmwork: prefetched GROUP.md for ${groupMdCache.size} groups`);
if (memberCount > 0) {
log?.info?.(`dmwork: prefetched ${memberCount} member names from ${groups.length} groups`);
}
} catch (err) {
log?.error?.(`dmwork: GROUP.md prefetch failed: ${String(err)}`);
log?.error?.(`dmwork: group prefetch failed: ${String(err)}`);
}
})();

Expand Down
54 changes: 48 additions & 6 deletions openclaw-channel-dmwork/src/inbound.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import type { ChannelLogSink, OpenClawConfig } from "openclaw/plugin-sdk";
import { sendMessage, sendReadReceipt, sendTyping, getChannelMessages, getGroupMembers, getGroupMd, postJson, sendMediaMessage, inferContentType, ensureTextCharset, parseImageDimensions, parseImageDimensionsFromFile, getUploadCredentials, uploadFileToCOS } from "./api-fetch.js";
import { sendMessage, sendReadReceipt, sendTyping, getChannelMessages, getGroupMembers, getGroupMd, postJson, sendMediaMessage, inferContentType, ensureTextCharset, parseImageDimensions, parseImageDimensionsFromFile, getUploadCredentials, uploadFileToCOS, fetchUserInfo } from "./api-fetch.js";
import type { ResolvedDmworkAccount } from "./accounts.js";
import type { BotMessage } from "./types.js";
import { ChannelType, MessageType } from "./types.js";
Expand All @@ -10,6 +10,7 @@ import {
extractMentionUids,
convertContentForLLM,
buildSenderPrefix,
resolveSenderName,
parseStructuredMentions,
convertStructuredMentions,
buildEntitiesFromFallback,
Expand Down Expand Up @@ -1035,6 +1036,14 @@ export async function handleInboundMessage(params: {
const resolved = resolveContent(message.payload, account.config.apiUrl, log, account.config.cdnUrl);
let rawBody = resolved.text;
let inboundMediaUrl = resolved.mediaUrl;

// Opportunistic uid→name cache fill from MultipleForward payloads
if (message.payload?.type === MessageType.MultipleForward && Array.isArray(message.payload.users)) {
for (const u of message.payload.users as Array<{ uid?: string; name?: string }>) {
if (u.uid && u.name) uidToNameMap.set(u.uid, u.name);
}
}

// For Image/GIF/Voice/Video: download media to local temp file so Core reads
// local files instead of remote URLs (avoids hang on large/slow downloads in Core)
const mediaDownloadTypes = [MessageType.Image, MessageType.GIF, MessageType.Voice, MessageType.Video];
Expand Down Expand Up @@ -1091,6 +1100,10 @@ export async function handleInboundMessage(params: {
quotePrefix = `[Quoted message from ${replyFrom}]: ${replyContent}\n---\n`;
log?.info?.(`dmwork: message quotes a reply (${quotePrefix.length} chars)`);
}
// Cache reply sender name for uid→name resolution (opportunistic fill)
if (replyData.from_uid && replyData.from_name) {
uidToNameMap.set(replyData.from_uid, replyData.from_name);
}
}

// --- Mention gating for group messages ---
Expand All @@ -1105,15 +1118,18 @@ export async function handleInboundMessage(params: {
await refreshGroupMemberCache({ sessionId, memberMap, uidToNameMap, groupCacheTimestamps, apiUrl: account.config.apiUrl, botToken: account.config.botToken ?? "", log });
}

if (isGroup && requireMention) {
// Compute isMentioned at top level so it's available for WasMentioned in finalizeInboundContext
let isMentioned = false;
if (isGroup) {
const mentionUids = extractMentionUids(message.payload?.mention);
// mention.all can be boolean `true` or numeric `1` depending on API version
const mentionAllRaw = message.payload?.mention?.all;
const mentionAll: boolean = mentionAllRaw === true || mentionAllRaw === 1;
const isMentioned = mentionAll || mentionUids.includes(botUid);

isMentioned = mentionAll || mentionUids.includes(botUid);
}

if (isGroup && requireMention) {
// Debug: log received mention info
log?.debug?.(`dmwork: [RECV] mention payload: uidsCount=${mentionUids.length}, all=${mentionAll}, originalCount=${originalMentionUids.length}`);
log?.debug?.(`dmwork: [RECV] mention payload: isMentioned=${isMentioned}, originalCount=${originalMentionUids.length}`);
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor: the old debug log included uidsCount and all which was useful for diagnosing mention-gating edge cases (e.g. "why was this message gated even though all=true?"). The new log with just isMentioned loses that granularity.

Since mentionUids and mentionAll are now scoped inside the if (isGroup) block above, they're not accessible here. Consider either hoisting them or re-referencing from the payload:

log?.debug?.(`dmwork: [RECV] mention payload: isMentioned=${isMentioned}, all=${message.payload?.mention?.all}, originalCount=${originalMentionUids.length}`);


if (!isMentioned) {
// Record as pending history context (manual — avoids SDK format incompatibility)
Expand Down Expand Up @@ -1302,6 +1318,29 @@ export async function handleInboundMessage(params: {
// Inject GROUP.md as GroupSystemPrompt for group messages
const groupSystemPrompt = isGroup && groupMdCache ? groupMdCache.get(message.channel_id!)?.content : undefined;

// Resolve sender display name — async fallback for DM users not in cache
let senderName = resolveSenderName(message.from_uid, uidToNameMap);
if (!senderName && !isGroup) {
// DM user not in any group cache — try backend user info API
// Skip if we already tried and failed (negative cache sentinel "")
const cached = uidToNameMap.get(message.from_uid);
if (cached === undefined) {
const userInfo = await fetchUserInfo({
apiUrl: account.config.apiUrl,
botToken: account.config.botToken ?? "",
uid: message.from_uid,
log,
});
if (userInfo?.name) {
senderName = userInfo.name;
uidToNameMap.set(message.from_uid, userInfo.name);
} else {
// Negative cache — prevent repeated API calls for unknown UIDs
uidToNameMap.set(message.from_uid, "");
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor edge case worth noting: the negative cache uses "" (empty string) as a sentinel, and resolveSenderName treats any falsy value from uidToNameMap.get() as "not found" (line 345: if (direct) return direct). This works correctly end-to-end because the caller checks cached === undefined (line 1327) before the fetchUserInfo path.

However, there's a subtle interaction: if resolveSenderName is called for a uid with a "" sentinel, the direct lookup falls through (falsy), and the cross-space scan could match the same entry and return "". The caller sees falsy senderName, enters the DM block, and cached === undefined correctly prevents re-fetching — so it's fine.

But it's a bit fragile. Consider using a dedicated sentinel (e.g. a separate Set<string> of failed UIDs) to make the negative-cache intent explicit and decouple it from resolveSenderName's truthiness checks. Not blocking, just a resilience suggestion.

}
}
}

const ctxPayload = core.channel.reply.finalizeInboundContext({
Body: body,
BodyForAgent: body, // ← 关键!AI 实际读取的是这个字段!
Expand All @@ -1321,6 +1360,9 @@ export async function handleInboundMessage(params: {
ChatType: isGroup ? "group" : "direct",
ConversationLabel: fromLabel,
SenderId: message.from_uid,
SenderName: senderName,
SenderUsername: message.from_uid,
WasMentioned: isGroup ? isMentioned : undefined,
MessageSid: String(message.message_id),
Timestamp: message.timestamp ? message.timestamp * 1000 : undefined,
GroupSubject: isGroup ? message.channel_id : undefined,
Expand Down
72 changes: 72 additions & 0 deletions openclaw-channel-dmwork/src/mention-utils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -486,3 +486,75 @@ describe("边界情况", () => {
expect(allEntities[1]).toEqual({ uid: "uid_bob", offset: 13, length: 4 });
});
});

// --- extractBaseUid & resolveSenderName ---
import { extractBaseUid, resolveSenderName } from "./mention-utils.js";

describe("extractBaseUid", () => {
it("strips space prefix", () => {
expect(extractBaseUid("s14_abc123")).toBe("abc123");
});

it("handles multi-digit space id", () => {
expect(extractBaseUid("s1234_user456")).toBe("user456");
});

it("returns uid unchanged when no space prefix", () => {
expect(extractBaseUid("abc123")).toBe("abc123");
});

it("returns uid unchanged for 's' without underscore", () => {
expect(extractBaseUid("system")).toBe("system");
});

it("does not strip non-numeric space prefix (e.g. service_bot)", () => {
expect(extractBaseUid("service_bot")).toBe("service_bot");
expect(extractBaseUid("support_team")).toBe("support_team");
});
});

describe("resolveSenderName", () => {
it("returns direct match", () => {
const map = new Map([["s14_abc", "Alice"]]);
expect(resolveSenderName("s14_abc", map)).toBe("Alice");
});

it("returns undefined when no match", () => {
const map = new Map([["s14_abc", "Alice"]]);
expect(resolveSenderName("s14_xyz", map)).toBeUndefined();
});

it("falls back to base uid (non-space entry)", () => {
const map = new Map([["abc", "Alice"]]);
expect(resolveSenderName("s14_abc", map)).toBe("Alice");
});

it("falls back to cross-space variant", () => {
// User known as s10_abc in one space, DM from s14_abc
const map = new Map([["s10_abc", "Alice"]]);
expect(resolveSenderName("s14_abc", map)).toBe("Alice");
});

it("does not cross-space fallback for non-prefixed uid", () => {
// uid "abc" without space prefix should not scan
const map = new Map([["s10_abc", "Alice"]]);
expect(resolveSenderName("abc", map)).toBeUndefined();
});

it("prefers direct match over cross-space", () => {
const map = new Map([["s14_abc", "Alice-14"], ["s10_abc", "Alice-10"]]);
expect(resolveSenderName("s14_abc", map)).toBe("Alice-14");
});
});

describe("buildSenderPrefix with cross-space", () => {
it("shows name(uid) for cross-space hit", () => {
const map = new Map([["s10_abc", "Alice"]]);
expect(buildSenderPrefix("s14_abc", map)).toBe("Alice(s14_abc)");
});

it("shows raw uid when no match", () => {
const map = new Map([["s10_xyz", "Bob"]]);
expect(buildSenderPrefix("s14_abc", map)).toBe("s14_abc");
});
});
Loading
Loading