mirror of
https://github.com/moltbot/moltbot.git
synced 2026-05-13 15:47:28 +00:00
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:
committed by
GitHub
parent
95a87f2f21
commit
59326c8e3b
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -2,3 +2,4 @@
|
||||
|
||||
export * from "../infra/heartbeat-events.js";
|
||||
export * from "../infra/heartbeat-visibility.js";
|
||||
export { requestHeartbeat } from "../infra/heartbeat-wake.js";
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user