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
4 changes: 2 additions & 2 deletions openclaw-channel-dmwork/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

21 changes: 17 additions & 4 deletions openclaw-channel-dmwork/src/channel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -125,16 +125,26 @@ function getOrCreateGroupCacheTimestamps(accountId: string): Map<string, number>
}


// --- Group → Account mapping: tracks which account each group was received from ---
// --- Group → Account mapping: tracks which accounts are active in each group ---
// Used by handleAction to resolve the correct account when framework passes wrong accountId
const _groupToAccount = new Map<string, string>(); // groupNo → accountId
// A group may have multiple bots (1:N), so we store a Set of accountIds per group.
const _groupToAccount = new Map<string, Set<string>>(); // groupNo → Set<accountId>

export function registerGroupToAccount(groupNo: string, accountId: string): void {
_groupToAccount.set(groupNo, accountId);
let accounts = _groupToAccount.get(groupNo);
if (!accounts) {
accounts = new Set<string>();
_groupToAccount.set(groupNo, accounts);
}
accounts.add(accountId);
}

export function resolveAccountForGroup(groupNo: string): string | undefined {
return _groupToAccount.get(groupNo);
const accounts = _groupToAccount.get(groupNo);
if (!accounts || accounts.size === 0) return undefined;
// Only resolve when exactly one bot owns the group; multi-bot → ambiguous
if (accounts.size === 1) return accounts.values().next().value;
return undefined;
}

// --- Cache cleanup: evict groups inactive for >4 hours ---
Expand Down Expand Up @@ -253,11 +263,14 @@ export const dmworkPlugin: ChannelPlugin<ResolvedDmworkAccount> = {
handleAction: async (ctx: any) => {
// Resolve correct accountId: framework may pass wrong one when agent has multiple accounts.
// Use currentChannelId to look up which account actually owns the group.
// When multiple bots share the same group, do NOT correct — the caller's accountId is authoritative.
let accountId = ctx.accountId ?? DEFAULT_ACCOUNT_ID;
const currentChannelId = ctx.toolContext?.currentChannelId;
if (currentChannelId) {
const rawGroupNo = currentChannelId.replace(/^dmwork:/, '');
const correctAccountId = resolveAccountForGroup(rawGroupNo);
// Only correct when resolveAccountForGroup returns a definitive answer
// (exactly one bot owns the group); multi-bot → undefined → no correction
if (correctAccountId && correctAccountId !== accountId) {
ctx.log?.info?.(`dmwork: handleAction accountId corrected: ${accountId} → ${correctAccountId} (group=${rawGroupNo})`);
accountId = correctAccountId;
Expand Down
208 changes: 208 additions & 0 deletions openclaw-channel-dmwork/src/multi-bot-isolation.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,208 @@
/**
* Tests for multi-bot accountId isolation fix.
*
* Verifies that when multiple bots share the same OpenClaw Gateway process,
* messages are sent from the correct bot account — not from whichever bot
* last processed a message in the same group.
*/
import { describe, it, expect, vi, beforeEach } from "vitest";

// We need to reset module state between tests since _groupToAccount is module-level
beforeEach(() => {
vi.resetModules();
});

// ─── registerGroupToAccount / resolveAccountForGroup unit tests ─────────────

describe("registerGroupToAccount + resolveAccountForGroup", () => {
it("single bot — resolveAccountForGroup returns the registered accountId", async () => {
const { registerGroupToAccount, resolveAccountForGroup } = await import("./channel.js");

registerGroupToAccount("group-001", "botA");

expect(resolveAccountForGroup("group-001")).toBe("botA");
});

it("multi-bot same group — resolveAccountForGroup returns undefined", async () => {
const { registerGroupToAccount, resolveAccountForGroup } = await import("./channel.js");

registerGroupToAccount("group-001", "botA");
registerGroupToAccount("group-001", "botB");

expect(resolveAccountForGroup("group-001")).toBeUndefined();
});

it("unregistered group — resolveAccountForGroup returns undefined", async () => {
const { resolveAccountForGroup } = await import("./channel.js");

expect(resolveAccountForGroup("group-unknown")).toBeUndefined();
});

it("duplicate registration of same bot is idempotent", async () => {
const { registerGroupToAccount, resolveAccountForGroup } = await import("./channel.js");

registerGroupToAccount("group-001", "botA");
registerGroupToAccount("group-001", "botA");

// Still size 1 → should return the accountId
expect(resolveAccountForGroup("group-001")).toBe("botA");
});

it("different groups with different bots resolve independently", async () => {
const { registerGroupToAccount, resolveAccountForGroup } = await import("./channel.js");

registerGroupToAccount("group-001", "botA");
registerGroupToAccount("group-002", "botB");

expect(resolveAccountForGroup("group-001")).toBe("botA");
expect(resolveAccountForGroup("group-002")).toBe("botB");
});
});

// ─── handleAction correction logic tests ─────────────────────────────────────

// Mock dependencies that handleAction calls
vi.mock("./actions.js", () => ({
handleDmworkMessageAction: vi.fn(async () => ({ ok: true })),
parseTarget: vi.fn(() => ({ channelId: "test", channelType: 2 })),
}));

vi.mock("./agent-tools.js", () => ({
createDmworkManagementTools: vi.fn(() => []),
}));

vi.mock("./group-md.js", () => ({
getOrCreateGroupMdCache: vi.fn(() => new Map()),
registerBotGroupIds: vi.fn(),
getKnownGroupIds: vi.fn(() => new Set()),
}));

vi.mock("./api-fetch.js", () => ({
registerBot: vi.fn(),
sendMessage: vi.fn(),
sendHeartbeat: vi.fn(),
sendMediaMessage: vi.fn(),
inferContentType: vi.fn(),
ensureTextCharset: vi.fn((s: string) => s),
fetchBotGroups: vi.fn(async () => []),
getGroupMd: vi.fn(),
getGroupMembers: vi.fn(),
parseImageDimensions: vi.fn(),
parseImageDimensionsFromFile: vi.fn(),
getUploadCredentials: vi.fn(),
uploadFileToCOS: vi.fn(),
}));

describe("handleAction multi-bot isolation", () => {
it("single bot — corrects wrong accountId to the sole owner", async () => {
const { dmworkPlugin, registerGroupToAccount } = await import("./channel.js");
const { handleDmworkMessageAction } = await import("./actions.js");

// Only botA is in group-001
registerGroupToAccount("group-001", "botA");

const ctx = {
accountId: "wrongBot",
action: "send" as const,
channel: "dmwork",
params: { target: "group:group-001", text: "hello" },
toolContext: { currentChannelId: "dmwork:group-001" },
cfg: {
channels: {
dmwork: {
accounts: {
botA: { botToken: "tokenA", apiUrl: "http://api" },
wrongBot: { botToken: "tokenWrong", apiUrl: "http://api" },
},
},
},
},
log: { info: vi.fn() },
};

await dmworkPlugin.actions!.handleAction!(ctx as any);

// handleDmworkMessageAction should have been called with botA's token
expect(handleDmworkMessageAction).toHaveBeenCalledWith(
expect.objectContaining({ botToken: "tokenA" }),
);
// Correction log should have fired
expect(ctx.log.info).toHaveBeenCalledWith(
expect.stringContaining("accountId corrected"),
);
});

it("multi-bot same group — does NOT override ctx.accountId", async () => {
const { dmworkPlugin, registerGroupToAccount } = await import("./channel.js");
const { handleDmworkMessageAction } = await import("./actions.js");

// Both botA and botB are in group-001
registerGroupToAccount("group-001", "botA");
registerGroupToAccount("group-001", "botB");

const ctx = {
accountId: "botA",
action: "send" as const,
channel: "dmwork",
params: { target: "group:group-001", text: "hello from A" },
toolContext: { currentChannelId: "dmwork:group-001" },
cfg: {
channels: {
dmwork: {
accounts: {
botA: { botToken: "tokenA", apiUrl: "http://api" },
botB: { botToken: "tokenB", apiUrl: "http://api" },
},
},
},
},
log: { info: vi.fn() },
};

await dmworkPlugin.actions!.handleAction!(ctx as any);

// Should use botA's token (the caller's original accountId), NOT botB's
expect(handleDmworkMessageAction).toHaveBeenCalledWith(
expect.objectContaining({ botToken: "tokenA" }),
);
// No correction log should have fired
expect(ctx.log.info).not.toHaveBeenCalledWith(
expect.stringContaining("accountId corrected"),
);
});

it("single bot — correct accountId is not re-corrected", async () => {
const { dmworkPlugin, registerGroupToAccount } = await import("./channel.js");
const { handleDmworkMessageAction } = await import("./actions.js");

registerGroupToAccount("group-001", "botA");

const ctx = {
accountId: "botA", // already correct
action: "send" as const,
channel: "dmwork",
params: { target: "group:group-001", text: "hello" },
toolContext: { currentChannelId: "dmwork:group-001" },
cfg: {
channels: {
dmwork: {
accounts: {
botA: { botToken: "tokenA", apiUrl: "http://api" },
},
},
},
},
log: { info: vi.fn() },
};

await dmworkPlugin.actions!.handleAction!(ctx as any);

expect(handleDmworkMessageAction).toHaveBeenCalledWith(
expect.objectContaining({ botToken: "tokenA" }),
);
// No correction needed
expect(ctx.log.info).not.toHaveBeenCalledWith(
expect.stringContaining("accountId corrected"),
);
});
});
Loading