Skip to content

Commit a910de2

Browse files
committed
Fix Slack DM delivery mirror routing
1 parent 4c1e6ba commit a910de2

5 files changed

Lines changed: 330 additions & 31 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ Docs: https://docs.openclaw.ai
2222
- OpenAI/realtime voice: honor disabled input-audio interruption locally so server VAD speech-start events do not clear Discord playback after operators set `interruptResponseOnInputAudio: false`.
2323
- Telegram: keep no-response DM turns quiet instead of rewriting them into visible silent-reply chatter. Fixes #78188. (#78228) Thanks @Beandon13.
2424
- Telegram: handle managed select button callbacks before the raw callback fallback while preserving delimiter-containing option values such as `env|prod`. (#79816) Thanks @moeedahmed.
25+
- Slack: canonicalize outbound delivery-mirror routes for native DM channel IDs to the peer user session, preventing `message.send` calls to `D...` targets from splitting the same Slack DM thread into a separate channel session. Fixes #80091.
2526
- OpenAI-compatible models: handle JSON chat-completion bodies returned to streaming requests, preserving reasoning fields and visible text instead of completing an empty agent turn. Fixes #77870.
2627
- xAI: expose `/think low|medium|high` for reasoning-capable Grok models and keep `reasoning.effort` on native Responses payloads while preserving off-only behavior for non-reasoning routes. Fixes #79210. Thanks @colinmcintosh.
2728
- CLI/media: let explicit image description model refs use bundled static provider catalogs and generic model-backed image hooks, so `openclaw infer image describe --model zai/glm-4.6v` works like direct model runs and Anthropic auth probes avoid stale Claude 3 Haiku catalog entries.

extensions/slack/src/channel-type.test.ts

Lines changed: 132 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
11
import { beforeEach, describe, expect, it, vi } from "vitest";
2-
import { __resetSlackChannelTypeCacheForTest, resolveSlackChannelType } from "./channel-type.js";
2+
import {
3+
__resetSlackChannelTypeCacheForTest,
4+
resolveSlackChannelType,
5+
resolveSlackConversationInfo,
6+
} from "./channel-type.js";
37

48
const conversationsInfoMock = vi.fn();
59

@@ -58,4 +62,131 @@ describe("resolveSlackChannelType", () => {
5862

5963
expect(conversationsInfoMock).not.toHaveBeenCalled();
6064
});
65+
66+
it("returns Slack IM peer user metadata from conversations.info", async () => {
67+
conversationsInfoMock.mockResolvedValueOnce({
68+
channel: {
69+
id: "D0AEWSDHAQH",
70+
is_im: true,
71+
user: "U09G2DJ0275",
72+
},
73+
});
74+
75+
await expect(
76+
resolveSlackConversationInfo({
77+
cfg: {
78+
channels: {
79+
slack: {
80+
botToken: "xoxb-test",
81+
},
82+
},
83+
} as never,
84+
channelId: "D0AEWSDHAQH",
85+
}),
86+
).resolves.toEqual({
87+
type: "dm",
88+
user: "U09G2DJ0275",
89+
});
90+
expect(conversationsInfoMock).toHaveBeenCalledWith({ channel: "D0AEWSDHAQH" });
91+
});
92+
93+
it("keeps D-prefixed channels typed as dm when Slack lookup fails", async () => {
94+
conversationsInfoMock.mockRejectedValueOnce(new Error("missing_scope"));
95+
96+
await expect(
97+
resolveSlackConversationInfo({
98+
cfg: {
99+
channels: {
100+
slack: {
101+
botToken: "xoxb-test",
102+
},
103+
},
104+
} as never,
105+
channelId: "D0AEWSDHAQH",
106+
}),
107+
).resolves.toEqual({
108+
type: "dm",
109+
});
110+
});
111+
112+
it("does not cache incomplete native IM channel lookups", async () => {
113+
conversationsInfoMock
114+
.mockRejectedValueOnce(new Error("temporary_failure"))
115+
.mockResolvedValueOnce({
116+
channel: {
117+
id: "D0AEWSDHAQH",
118+
is_im: true,
119+
user: "U09G2DJ0275",
120+
},
121+
});
122+
123+
const cfg = {
124+
channels: {
125+
slack: {
126+
botToken: "xoxb-test",
127+
},
128+
},
129+
} as never;
130+
131+
await expect(
132+
resolveSlackConversationInfo({
133+
cfg,
134+
channelId: "D0AEWSDHAQH",
135+
}),
136+
).resolves.toEqual({
137+
type: "dm",
138+
});
139+
await expect(
140+
resolveSlackConversationInfo({
141+
cfg,
142+
channelId: "D0AEWSDHAQH",
143+
}),
144+
).resolves.toEqual({
145+
type: "dm",
146+
user: "U09G2DJ0275",
147+
});
148+
expect(conversationsInfoMock).toHaveBeenCalledTimes(2);
149+
});
150+
151+
it("does not let group-channel overrides reclassify native IM channel ids", async () => {
152+
await expect(
153+
resolveSlackConversationInfo({
154+
cfg: {
155+
channels: {
156+
slack: {
157+
dm: {
158+
groupChannels: ["D0AEWSDHAQH"],
159+
},
160+
},
161+
},
162+
} as never,
163+
channelId: "D0AEWSDHAQH",
164+
}),
165+
).resolves.toEqual({
166+
type: "dm",
167+
});
168+
expect(conversationsInfoMock).not.toHaveBeenCalled();
169+
});
170+
171+
it("preserves the channel-type wrapper contract", async () => {
172+
conversationsInfoMock.mockResolvedValueOnce({
173+
channel: {
174+
id: "G123",
175+
is_mpim: true,
176+
},
177+
});
178+
179+
await expect(
180+
resolveSlackChannelType({
181+
cfg: {
182+
channels: {
183+
slack: {
184+
botToken: "xoxb-test",
185+
},
186+
},
187+
} as never,
188+
channelId: "G123",
189+
}),
190+
).resolves.toBe("group");
191+
});
61192
});

extensions/slack/src/channel-type.ts

Lines changed: 55 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -7,38 +7,47 @@ import { createSlackWebClient } from "./client.js";
77
import { normalizeAllowListLower } from "./monitor/allow-list.js";
88
import type { OpenClawConfig } from "./runtime-api.js";
99

10-
const SLACK_CHANNEL_TYPE_CACHE = new Map<string, "channel" | "group" | "dm" | "unknown">();
10+
export type SlackConversationInfo = {
11+
type: "channel" | "group" | "dm" | "unknown";
12+
user?: string;
13+
};
1114

12-
export async function resolveSlackChannelType(params: {
15+
const SLACK_CONVERSATION_INFO_CACHE = new Map<string, SlackConversationInfo>();
16+
17+
export async function resolveSlackConversationInfo(params: {
1318
cfg: OpenClawConfig;
1419
accountId?: string | null;
1520
channelId: string;
16-
}): Promise<"channel" | "group" | "dm" | "unknown"> {
21+
}): Promise<SlackConversationInfo> {
1722
const channelId = params.channelId.trim();
1823
if (!channelId) {
19-
return "unknown";
24+
return { type: "unknown" };
2025
}
2126
const account = resolveSlackAccount({ cfg: params.cfg, accountId: params.accountId });
2227
const cacheKey = `${account.accountId}:${channelId}`;
23-
const cached = SLACK_CHANNEL_TYPE_CACHE.get(cacheKey);
28+
const cached = SLACK_CONVERSATION_INFO_CACHE.get(cacheKey);
2429
if (cached) {
2530
return cached;
2631
}
32+
const isNativeImChannel = /^D/i.test(channelId);
2733
const groupChannels = normalizeAllowListLower(account.dm?.groupChannels);
2834
const channelIdLower = normalizeLowercaseStringOrEmpty(channelId);
2935
if (
30-
groupChannels.includes(channelIdLower) ||
31-
groupChannels.includes(`slack:${channelIdLower}`) ||
32-
groupChannels.includes(`channel:${channelIdLower}`) ||
33-
groupChannels.includes(`group:${channelIdLower}`) ||
34-
groupChannels.includes(`mpim:${channelIdLower}`)
36+
!isNativeImChannel &&
37+
(groupChannels.includes(channelIdLower) ||
38+
groupChannels.includes(`slack:${channelIdLower}`) ||
39+
groupChannels.includes(`channel:${channelIdLower}`) ||
40+
groupChannels.includes(`group:${channelIdLower}`) ||
41+
groupChannels.includes(`mpim:${channelIdLower}`))
3542
) {
36-
SLACK_CHANNEL_TYPE_CACHE.set(cacheKey, "group");
37-
return "group";
43+
const result = { type: "group" } as const;
44+
SLACK_CONVERSATION_INFO_CACHE.set(cacheKey, result);
45+
return result;
3846
}
3947

4048
const channelKeys = Object.keys(account.channels ?? {});
4149
if (
50+
!isNativeImChannel &&
4251
channelKeys.some((key) => {
4352
const normalized = normalizeLowercaseStringOrEmpty(key);
4453
return (
@@ -48,32 +57,55 @@ export async function resolveSlackChannelType(params: {
4857
);
4958
})
5059
) {
51-
SLACK_CHANNEL_TYPE_CACHE.set(cacheKey, "channel");
52-
return "channel";
60+
const result = { type: "channel" } as const;
61+
SLACK_CONVERSATION_INFO_CACHE.set(cacheKey, result);
62+
return result;
5363
}
5464

5565
const token =
5666
normalizeOptionalString(account.botToken) ??
5767
normalizeOptionalString(account.config.userToken) ??
5868
"";
5969
if (!token) {
60-
SLACK_CHANNEL_TYPE_CACHE.set(cacheKey, "unknown");
61-
return "unknown";
70+
const result = { type: isNativeImChannel ? "dm" : "unknown" } as const;
71+
if (!isNativeImChannel) {
72+
SLACK_CONVERSATION_INFO_CACHE.set(cacheKey, result);
73+
}
74+
return result;
6275
}
6376

6477
try {
6578
const client = createSlackWebClient(token);
6679
const info = await client.conversations.info({ channel: channelId });
67-
const channel = info.channel as { is_im?: boolean; is_mpim?: boolean } | undefined;
68-
const type = channel?.is_im ? "dm" : channel?.is_mpim ? "group" : "channel";
69-
SLACK_CHANNEL_TYPE_CACHE.set(cacheKey, type);
70-
return type;
80+
const channel = info.channel as
81+
| { is_im?: boolean; is_mpim?: boolean; user?: unknown }
82+
| undefined;
83+
const type =
84+
isNativeImChannel || channel?.is_im ? "dm" : channel?.is_mpim ? "group" : "channel";
85+
const user =
86+
typeof channel?.user === "string" && channel.user.trim() ? channel.user.trim() : undefined;
87+
const result: SlackConversationInfo = user ? { type, user } : { type };
88+
if (type !== "dm" || user) {
89+
SLACK_CONVERSATION_INFO_CACHE.set(cacheKey, result);
90+
}
91+
return result;
7192
} catch {
72-
SLACK_CHANNEL_TYPE_CACHE.set(cacheKey, "unknown");
73-
return "unknown";
93+
const result = { type: isNativeImChannel ? "dm" : "unknown" } as const;
94+
if (!isNativeImChannel) {
95+
SLACK_CONVERSATION_INFO_CACHE.set(cacheKey, result);
96+
}
97+
return result;
7498
}
7599
}
76100

101+
export async function resolveSlackChannelType(params: {
102+
cfg: OpenClawConfig;
103+
accountId?: string | null;
104+
channelId: string;
105+
}): Promise<"channel" | "group" | "dm" | "unknown"> {
106+
return (await resolveSlackConversationInfo(params)).type;
107+
}
108+
77109
export function __resetSlackChannelTypeCacheForTest(): void {
78-
SLACK_CHANNEL_TYPE_CACHE.clear();
110+
SLACK_CONVERSATION_INFO_CACHE.clear();
79111
}

0 commit comments

Comments
 (0)