fix(slack): wake interactive reply sessions (#79836)

Merged via squash.

Prepared head SHA: 2bc9182d0f
Co-authored-by: velvet-shark <126378+velvet-shark@users.noreply.github.com>
Co-authored-by: velvet-shark <126378+velvet-shark@users.noreply.github.com>
Reviewed-by: @velvet-shark
This commit is contained in:
Radek Sienkiewicz
2026-05-09 20:31:40 +02:00
committed by GitHub
parent 95a87f2f21
commit 59326c8e3b
13 changed files with 170 additions and 23 deletions

View File

@@ -12,6 +12,7 @@ Docs: https://docs.openclaw.ai
- Feishu: auto-thread `message(action="send")` replies inside the topic when the active session is group_topic or group_topic_sender, and propagate `replyInThread` through text, card, and media outbound adapters so topic-scoped sessions no longer post at the group root. Fixes #74903. (#77151) Thanks @ai-hpc.
- WhatsApp: pass routing context into voice-note transcript echo preflight so echoed transcripts can deliver to the originating chat. Fixes #79778. (#79788) Thanks @hclsys.
- Cron/failover: classify structured OpenAI-compatible `server_error` payloads as `server_error`, expose that reason in cron state, and let one-shot cron retry policy honor `retryOn: ["server_error"]` without requiring raw `5xx` text. (#45594) Thanks @clovericbot.
- Slack: wake the resolved thread session after interactive reply button/select clicks and carry Slack delivery context through the queued interaction event, so clicks continue the visible conversation. Fixes #79676 and #61502. (#79836) Thanks @velvet-shark, @tianxiaochannel-oss88, and @Saicheg.
## 2026.5.9

View File

@@ -1,2 +1,2 @@
1aa92e012261d21474fde2aea8b1724bea369b5bc730e9fb943ff82de73f38de plugin-sdk-api-baseline.json
f076fb0cc1d09b5d50502741428e88a52725d73db950745801ae4fe782d709e2 plugin-sdk-api-baseline.jsonl
fce2653618d6a41bd46fd4503b66f8ad912b9abee111cda24661ea1629c293b2 plugin-sdk-api-baseline.json
5d73209fe60e9ab89296242d1921d43d6b385d113155c17d31bf0bf174ad964c plugin-sdk-api-baseline.jsonl

View File

@@ -427,7 +427,7 @@ releases.
| Need | Import |
| --- | --- |
| System event queue helpers | `openclaw/plugin-sdk/system-event-runtime` |
| Heartbeat event and visibility helpers | `openclaw/plugin-sdk/heartbeat-runtime` |
| Heartbeat wake, event, and visibility helpers | `openclaw/plugin-sdk/heartbeat-runtime` |
| Pending delivery queue drain | `openclaw/plugin-sdk/delivery-queue-runtime` |
| Channel activity telemetry | `openclaw/plugin-sdk/channel-activity-runtime` |
| In-memory dedupe caches | `openclaw/plugin-sdk/dedupe-runtime` |
@@ -545,7 +545,7 @@ releases.
| `plugin-sdk/ssrf-policy` | SSRF policy helpers | Host allowlist and private-network policy helpers |
| `plugin-sdk/ssrf-runtime` | SSRF runtime helpers | Pinned-dispatcher, guarded fetch, SSRF policy helpers |
| `plugin-sdk/system-event-runtime` | System event helpers | `enqueueSystemEvent`, `peekSystemEventEntries` |
| `plugin-sdk/heartbeat-runtime` | Heartbeat helpers | Heartbeat event and visibility helpers |
| `plugin-sdk/heartbeat-runtime` | Heartbeat helpers | Heartbeat wake, event, and visibility helpers |
| `plugin-sdk/delivery-queue-runtime` | Delivery queue helpers | `drainPendingDeliveries` |
| `plugin-sdk/channel-activity-runtime` | Channel activity helpers | `recordChannelActivity` |
| `plugin-sdk/dedupe-runtime` | Dedupe helpers | In-memory dedupe caches |

View File

@@ -238,7 +238,7 @@ For the plugin authoring guide, see [Plugin SDK overview](/plugins/sdk-overview)
| `plugin-sdk/dedupe-runtime` | In-memory dedupe cache helpers |
| `plugin-sdk/delivery-queue-runtime` | Outbound pending-delivery drain helper |
| `plugin-sdk/file-access-runtime` | Safe local-file and media-source path helpers |
| `plugin-sdk/heartbeat-runtime` | Heartbeat event and visibility helpers |
| `plugin-sdk/heartbeat-runtime` | Heartbeat wake, event, and visibility helpers |
| `plugin-sdk/number-runtime` | Numeric coercion helper |
| `plugin-sdk/secure-random-runtime` | Secure token/UUID helpers |
| `plugin-sdk/system-event-runtime` | System event queue helpers |

View File

@@ -83,3 +83,18 @@ describe("createSlackMonitorContext shouldDropMismatchedSlackEvent", () => {
).toBe(false);
});
});
describe("createSlackMonitorContext resolveSlackSystemEventSessionKey", () => {
it("routes threaded interaction events to the Slack thread session", () => {
const ctx = createTestContext();
expect(
ctx.resolveSlackSystemEventSessionKey({
channelId: "C_THREAD",
channelType: "channel",
senderId: "U_CLICKER",
threadTs: "1712345678.123456",
}),
).toBe("agent:main:slack:channel:c_thread:thread:1712345678.123456");
});
});

View File

@@ -7,10 +7,12 @@ import type {
} from "openclaw/plugin-sdk/config-types";
import type { SessionScope } from "openclaw/plugin-sdk/config-types";
import type { DmPolicy, GroupPolicy } from "openclaw/plugin-sdk/config-types";
import { resolveRuntimeConversationBindingRoute } from "openclaw/plugin-sdk/conversation-runtime";
import { createDedupeCache } from "openclaw/plugin-sdk/dedupe-runtime";
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
import type { HistoryEntry } from "openclaw/plugin-sdk/reply-history";
import { resolveAgentRoute } from "openclaw/plugin-sdk/routing";
import { resolveThreadSessionKeys } from "openclaw/plugin-sdk/routing";
import { logVerbose } from "openclaw/plugin-sdk/runtime-env";
import { getChildLogger } from "openclaw/plugin-sdk/runtime-env";
import type { RuntimeEnv } from "openclaw/plugin-sdk/runtime-env";
@@ -78,6 +80,7 @@ export type SlackMonitorContext = {
channelId?: string | null;
channelType?: string | null;
senderId?: string | null;
threadTs?: string | null;
}) => string;
isChannelAllowed: (params: {
channelId?: string;
@@ -178,6 +181,7 @@ export function createSlackMonitorContext(params: {
channelId?: string | null;
channelType?: string | null;
senderId?: string | null;
threadTs?: string | null;
}) => {
const channelId = normalizeOptionalString(p.channelId) ?? "";
if (!channelId) {
@@ -207,18 +211,58 @@ export function createSlackMonitorContext(params: {
teamId: params.teamId,
peer: { kind: peerKind, id: peerId },
});
return route.sessionKey;
const threadTs = normalizeOptionalString(p.threadTs);
const baseConversationId = isDirectMessage ? `user:${senderId}` : channelId;
const threadBindingRoute = threadTs
? resolveRuntimeConversationBindingRoute({
route,
conversation: {
channel: "slack",
accountId: params.accountId,
conversationId: threadTs,
parentConversationId: baseConversationId,
},
})
: null;
const runtimeRoute =
threadBindingRoute?.boundSessionKey || threadBindingRoute?.bindingRecord
? threadBindingRoute
: resolveRuntimeConversationBindingRoute({
route,
conversation: {
channel: "slack",
accountId: params.accountId,
conversationId: baseConversationId,
},
});
if (runtimeRoute.boundSessionKey) {
return runtimeRoute.route.sessionKey;
}
return resolveThreadSessionKeys({
baseSessionKey: runtimeRoute.route.sessionKey,
threadId: threadTs,
parentSessionKey:
threadTs && params.threadInheritParent ? runtimeRoute.route.sessionKey : undefined,
}).sessionKey;
}
} catch {
// Fall through to legacy key derivation.
}
return resolveSessionKey(
const legacySessionKey = resolveSessionKey(
params.sessionScope,
{ From: from, ChatType: chatType, Provider: "slack" },
params.mainKey,
resolveDefaultAgentId(params.cfg),
);
return resolveThreadSessionKeys({
baseSessionKey: legacySessionKey,
threadId: normalizeOptionalString(p.threadTs),
parentSessionKey:
normalizeOptionalString(p.threadTs) && params.threadInheritParent
? legacySessionKey
: undefined,
}).sessionKey;
};
const resolveChannelName = async (channelId: string) => {

View File

@@ -6,6 +6,7 @@ import {
resolveCommandAuthorization,
resolveCommandAuthorizedFromAuthorizers,
} from "openclaw/plugin-sdk/command-auth-native";
import { requestHeartbeat } from "openclaw/plugin-sdk/heartbeat-runtime";
import { enqueueSystemEvent } from "openclaw/plugin-sdk/system-event-runtime";
import { normalizeOptionalString } from "openclaw/plugin-sdk/text-runtime";
import {
@@ -778,6 +779,7 @@ function enqueueSlackBlockActionEvent(params: {
channelId: params.parsed.channelId,
channelType: params.auth.channelType,
senderId: params.parsed.userId,
threadTs: params.parsed.threadTs,
});
const contextParts = [
"slack:interaction",
@@ -785,10 +787,31 @@ function enqueueSlackBlockActionEvent(params: {
params.parsed.messageTs,
params.parsed.actionId,
].filter(Boolean);
enqueueSystemEvent(params.formatSystemEvent(eventPayload), {
const queued = enqueueSystemEvent(params.formatSystemEvent(eventPayload), {
sessionKey,
contextKey: contextParts.join(":"),
deliveryContext: {
channel: "slack",
to:
params.auth.channelType === "im"
? `user:${params.parsed.userId}`
: params.parsed.channelId
? `channel:${params.parsed.channelId}`
: undefined,
accountId: params.ctx.accountId,
threadId: params.parsed.threadTs,
},
trusted: false,
});
if (queued) {
requestHeartbeat({
source: "hook",
intent: "immediate",
reason: "hook:slack-interaction",
sessionKey,
heartbeat: { target: "last" },
});
}
}
function buildSlackConfirmationBlocks(params: {

View File

@@ -1,6 +1,7 @@
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
const enqueueSystemEventMock = vi.hoisted(() => vi.fn());
const requestHeartbeatMock = vi.hoisted(() => vi.fn());
type DispatchPluginInteractiveHandlerResult = {
matched: boolean;
handled: boolean;
@@ -29,6 +30,14 @@ vi.mock("openclaw/plugin-sdk/system-event-runtime", async (importOriginal) => {
};
});
vi.mock("openclaw/plugin-sdk/heartbeat-runtime", async (importOriginal) => {
const actual = await importOriginal<typeof import("openclaw/plugin-sdk/heartbeat-runtime")>();
return {
...actual,
requestHeartbeat: (...args: unknown[]) => requestHeartbeatMock(...args),
};
});
vi.mock("openclaw/plugin-sdk/approval-gateway-runtime", () => ({
resolveApprovalOverGateway: (arg: unknown) => resolveApprovalOverGatewayMock(arg),
}));
@@ -307,7 +316,9 @@ describe("registerSlackInteractionEvents", () => {
});
beforeEach(() => {
enqueueSystemEventMock.mockClear();
enqueueSystemEventMock.mockReset();
enqueueSystemEventMock.mockReturnValue(true);
requestHeartbeatMock.mockClear();
dispatchPluginInteractiveHandlerMock.mockClear();
resolvePluginConversationBindingApprovalMock.mockClear();
resolvePluginConversationBindingApprovalMock.mockResolvedValue({ status: "expired" });
@@ -394,6 +405,7 @@ describe("registerSlackInteractionEvents", () => {
channelId: "C1",
channelType: "channel",
senderId: "U123",
threadTs: "100.100",
});
expect(trackEvent).toHaveBeenCalledTimes(1);
expect(app.client.chat.update).toHaveBeenCalledTimes(1);
@@ -642,7 +654,7 @@ describe("registerSlackInteractionEvents", () => {
});
it("treats Slack reply buttons as plain interaction events instead of plugin dispatch", async () => {
const { ctx, app, getHandler } = createContext();
const { ctx, app, getHandler, resolveSessionKey } = createContext();
registerSlackInteractionEvents({ ctx: ctx as never });
const handler = getHandler();
@@ -679,8 +691,31 @@ describe("registerSlackInteractionEvents", () => {
expect(dispatchPluginInteractiveHandlerMock).not.toHaveBeenCalled();
expect(enqueueSystemEventMock).toHaveBeenCalledWith(
expect.stringContaining('"actionId":"openclaw:reply_button"'),
expect.any(Object),
expect.objectContaining({
contextKey: "slack:interaction:C1:100.200:openclaw:reply_button",
deliveryContext: {
accountId: "default",
channel: "slack",
threadId: "100.100",
to: "channel:C1",
},
sessionKey: "agent:ops:slack:channel:C1",
trusted: false,
}),
);
expect(resolveSessionKey).toHaveBeenCalledWith({
channelId: "C1",
channelType: "channel",
senderId: "U123",
threadTs: "100.100",
});
expect(requestHeartbeatMock).toHaveBeenCalledWith({
source: "hook",
intent: "immediate",
reason: "hook:slack-interaction",
sessionKey: "agent:ops:slack:channel:C1",
heartbeat: { target: "last" },
});
expect(app.client.chat.update).toHaveBeenCalledTimes(1);
});
@@ -1459,6 +1494,7 @@ describe("registerSlackInteractionEvents", () => {
channelId: "C222",
channelType: "channel",
senderId: "U111",
threadTs: "222.111",
});
expect(enqueueSystemEventMock).toHaveBeenCalledTimes(1);
const [eventText] = enqueueSystemEventMock.mock.calls[0] as [string];

View File

@@ -102,23 +102,39 @@ mutation($input: CreateCommitOnBranchInput!) {
GRAPHQL
)
local additions_file deletions_file
additions_file=$(mktemp)
deletions_file=$(mktemp)
printf '%s\n' "$additions" >"$additions_file"
printf '%s\n' "$deletions" >"$deletions_file"
local variables
variables=$(jq -n \
--arg nwo "$repo_nwo" \
--arg branch "$branch" \
--arg oid "$expected_oid" \
--arg headline "$commit_headline" \
--argjson additions "$additions" \
--argjson deletions "$deletions" \
--slurpfile additions "$additions_file" \
--slurpfile deletions "$deletions_file" \
'{input: {
branch: { repositoryNameWithOwner: $nwo, branchName: $branch },
message: { headline: $headline },
fileChanges: { additions: $additions, deletions: $deletions },
fileChanges: { additions: $additions[0], deletions: $deletions[0] },
expectedHeadOid: $oid
}}')
rm -f "$additions_file" "$deletions_file"
local variables_file
variables_file=$(mktemp)
printf '%s\n' "$variables" >"$variables_file"
local payload
payload=$(jq -n --arg query "$query" --slurpfile variables "$variables_file" \
'{query: $query, variables: $variables[0]}')
rm -f "$variables_file"
local result
result=$(gh api graphql -f query="$query" --input - <<< "$variables" 2>&1) || {
result=$(gh api graphql --input - <<< "$payload" 2>&1) || {
echo "GraphQL push failed: $result" >&2
return 1
}

View File

@@ -382,14 +382,17 @@ describe("buildInboundUserContextPrefix", () => {
});
it("includes formatted timestamp in conversation info when provided", () => {
const text = buildInboundUserContextPrefix({
ChatType: "group",
MessageSid: "msg-with-ts",
Timestamp: Date.UTC(2026, 1, 15, 13, 35),
} as TemplateContext);
const text = buildInboundUserContextPrefix(
{
ChatType: "group",
MessageSid: "msg-with-ts",
Timestamp: Date.UTC(2026, 1, 15, 13, 35),
} as TemplateContext,
{ timezone: "utc" },
);
const conversationInfo = parseConversationInfoPayload(text);
expect(conversationInfo["timestamp"]).toMatch(/^Sun 2026-02-15 13:35 (?:GMT|UTC)$/);
expect(conversationInfo["timestamp"]).toBe("Sun 2026-02-15T13:35Z");
});
it("honors envelope user timezone for conversation timestamps", () => {

View File

@@ -2,3 +2,4 @@
export * from "../infra/heartbeat-events.js";
export * from "../infra/heartbeat-visibility.js";
export { requestHeartbeat } from "../infra/heartbeat-wake.js";

View File

@@ -204,7 +204,7 @@ describe("version resolution", () => {
expect(resolveUsableRuntimeVersion(" 2026.3.2 ")).toBe("2026.3.2");
});
it("prefers runtime VERSION over service/package markers and ignores blank env values", () => {
it("prefers runtime VERSION over service/package markers and ignores unusable env values", () => {
expect(
resolveRuntimeServiceVersion({
OPENCLAW_VERSION: " ",
@@ -231,5 +231,13 @@ describe("version resolution", () => {
"fallback",
),
).toBe(VERSION);
expect(
resolveRuntimeServiceVersion({
OPENCLAW_VERSION: "undefined",
OPENCLAW_SERVICE_VERSION: "null",
npm_package_version: "1.0.0-package",
}),
).toBe(VERSION);
});
});

View File

@@ -48,7 +48,7 @@ function readVersionFromJsonCandidates(
function firstNonEmpty(...values: Array<string | undefined>): string | undefined {
for (const value of values) {
const trimmed = normalizeOptionalString(value);
if (trimmed) {
if (trimmed && trimmed.toLowerCase() !== "undefined" && trimmed.toLowerCase() !== "null") {
return trimmed;
}
}