diff --git a/CHANGELOG.md b/CHANGELOG.md index 9fa454a8df5..0b8a4b1d473 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/docs/.generated/plugin-sdk-api-baseline.sha256 b/docs/.generated/plugin-sdk-api-baseline.sha256 index 19a7c9c798e..f1894034f45 100644 --- a/docs/.generated/plugin-sdk-api-baseline.sha256 +++ b/docs/.generated/plugin-sdk-api-baseline.sha256 @@ -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 diff --git a/docs/plugins/sdk-migration.md b/docs/plugins/sdk-migration.md index 4bb2d9a0e64..7f57f6fa680 100644 --- a/docs/plugins/sdk-migration.md +++ b/docs/plugins/sdk-migration.md @@ -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 | diff --git a/docs/plugins/sdk-subpaths.md b/docs/plugins/sdk-subpaths.md index 7294bca03a8..a370c191df5 100644 --- a/docs/plugins/sdk-subpaths.md +++ b/docs/plugins/sdk-subpaths.md @@ -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 | diff --git a/extensions/slack/src/monitor/context.test.ts b/extensions/slack/src/monitor/context.test.ts index c6eb45c4cdd..fb43c9050ca 100644 --- a/extensions/slack/src/monitor/context.test.ts +++ b/extensions/slack/src/monitor/context.test.ts @@ -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"); + }); +}); diff --git a/extensions/slack/src/monitor/context.ts b/extensions/slack/src/monitor/context.ts index e1961bf8e88..12fdd0f9936 100644 --- a/extensions/slack/src/monitor/context.ts +++ b/extensions/slack/src/monitor/context.ts @@ -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) => { diff --git a/extensions/slack/src/monitor/events/interactions.block-actions.ts b/extensions/slack/src/monitor/events/interactions.block-actions.ts index be1e41d8581..9b8730efc41 100644 --- a/extensions/slack/src/monitor/events/interactions.block-actions.ts +++ b/extensions/slack/src/monitor/events/interactions.block-actions.ts @@ -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: { diff --git a/extensions/slack/src/monitor/events/interactions.test.ts b/extensions/slack/src/monitor/events/interactions.test.ts index 000e5296932..13688f3f095 100644 --- a/extensions/slack/src/monitor/events/interactions.test.ts +++ b/extensions/slack/src/monitor/events/interactions.test.ts @@ -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(); + 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]; diff --git a/scripts/pr-lib/push.sh b/scripts/pr-lib/push.sh index ed2b4cf4566..af308bd1e88 100644 --- a/scripts/pr-lib/push.sh +++ b/scripts/pr-lib/push.sh @@ -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 } diff --git a/src/auto-reply/reply/inbound-meta.test.ts b/src/auto-reply/reply/inbound-meta.test.ts index b20725db207..678512a9401 100644 --- a/src/auto-reply/reply/inbound-meta.test.ts +++ b/src/auto-reply/reply/inbound-meta.test.ts @@ -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", () => { diff --git a/src/plugin-sdk/heartbeat-runtime.ts b/src/plugin-sdk/heartbeat-runtime.ts index 15199157655..07bed7d05ef 100644 --- a/src/plugin-sdk/heartbeat-runtime.ts +++ b/src/plugin-sdk/heartbeat-runtime.ts @@ -2,3 +2,4 @@ export * from "../infra/heartbeat-events.js"; export * from "../infra/heartbeat-visibility.js"; +export { requestHeartbeat } from "../infra/heartbeat-wake.js"; diff --git a/src/version.test.ts b/src/version.test.ts index 19aef0ab62c..6b89d044ca2 100644 --- a/src/version.test.ts +++ b/src/version.test.ts @@ -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); }); }); diff --git a/src/version.ts b/src/version.ts index d4e78a14753..c060cb12164 100644 --- a/src/version.ts +++ b/src/version.ts @@ -48,7 +48,7 @@ function readVersionFromJsonCandidates( function firstNonEmpty(...values: Array): string | undefined { for (const value of values) { const trimmed = normalizeOptionalString(value); - if (trimmed) { + if (trimmed && trimmed.toLowerCase() !== "undefined" && trimmed.toLowerCase() !== "null") { return trimmed; } }