@open-everywhere/provider-slack 0.6.0 โ 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/agent/access/slack-attachment-fetch.ts +2 -1
- package/agent/app-manifest.ts +15 -1
- package/agent/channels/slack-agent-session.ts +249 -0
- package/agent/channels/slack-attachments.ts +20 -11
- package/agent/channels/slack-bot-ask.ts +101 -0
- package/agent/channels/slack-commands.ts +178 -17
- package/agent/channels/slack-context.ts +0 -5
- package/agent/channels/slack-events.ts +110 -33
- package/agent/channels/slack-follow-ups.ts +148 -0
- package/agent/channels/slack-friendly-names.ts +55 -0
- package/agent/channels/slack-front.ts +262 -27
- package/agent/channels/slack-home.ts +9 -3
- package/agent/channels/slack-identity.ts +9 -1
- package/agent/channels/slack-memory-canvas.ts +197 -54
- package/agent/channels/slack-origin-codec.ts +9 -0
- package/agent/channels/slack-post-arm.ts +68 -11
- package/agent/channels/slack-read.ts +27 -2
- package/agent/channels/slack-reply-blocks.ts +180 -0
- package/agent/channels/slack-reply-feedback.ts +75 -0
- package/agent/channels/slack-shortcut.ts +1 -1
- package/agent/channels/slack-voice-clips.ts +82 -0
- package/agent/channels/slack-wake-backscroll.ts +0 -5
- package/agent/channels/slack-web-api.ts +4 -2
- package/agent/channels/slack.ts +81 -19
- package/agent/docs-generated.ts +1282 -2
- package/agent/ops.ts +14 -21
- package/agent/register.ts +25 -0
- package/agent/setup/credentials.ts +17 -0
- package/agent/setup/page.ts +6 -3
- package/agent/surface-parity.ts +2 -0
- package/dist/agent/access/slack-attachment-fetch.d.ts.map +1 -1
- package/dist/agent/access/slack-attachment-fetch.js.map +1 -1
- package/dist/agent/app-manifest.d.ts.map +1 -1
- package/dist/agent/app-manifest.js +15 -1
- package/dist/agent/app-manifest.js.map +1 -1
- package/dist/agent/channels/slack-agent-session.d.ts +81 -0
- package/dist/agent/channels/slack-agent-session.d.ts.map +1 -0
- package/dist/agent/channels/slack-agent-session.js +198 -0
- package/dist/agent/channels/slack-agent-session.js.map +1 -0
- package/dist/agent/channels/slack-attachments.d.ts +11 -0
- package/dist/agent/channels/slack-attachments.d.ts.map +1 -1
- package/dist/agent/channels/slack-attachments.js +21 -14
- package/dist/agent/channels/slack-attachments.js.map +1 -1
- package/dist/agent/channels/slack-bot-ask.d.ts +30 -0
- package/dist/agent/channels/slack-bot-ask.d.ts.map +1 -0
- package/dist/agent/channels/slack-bot-ask.js +91 -0
- package/dist/agent/channels/slack-bot-ask.js.map +1 -0
- package/dist/agent/channels/slack-commands.d.ts +113 -10
- package/dist/agent/channels/slack-commands.d.ts.map +1 -1
- package/dist/agent/channels/slack-commands.js +151 -18
- package/dist/agent/channels/slack-commands.js.map +1 -1
- package/dist/agent/channels/slack-context.d.ts +1 -2
- package/dist/agent/channels/slack-context.d.ts.map +1 -1
- package/dist/agent/channels/slack-context.js +0 -4
- package/dist/agent/channels/slack-context.js.map +1 -1
- package/dist/agent/channels/slack-events.d.ts +47 -97
- package/dist/agent/channels/slack-events.d.ts.map +1 -1
- package/dist/agent/channels/slack-events.js +90 -22
- package/dist/agent/channels/slack-events.js.map +1 -1
- package/dist/agent/channels/slack-follow-ups.d.ts +44 -0
- package/dist/agent/channels/slack-follow-ups.d.ts.map +1 -0
- package/dist/agent/channels/slack-follow-ups.js +129 -0
- package/dist/agent/channels/slack-follow-ups.js.map +1 -0
- package/dist/agent/channels/slack-friendly-names.d.ts +10 -0
- package/dist/agent/channels/slack-friendly-names.d.ts.map +1 -0
- package/dist/agent/channels/slack-friendly-names.js +45 -0
- package/dist/agent/channels/slack-friendly-names.js.map +1 -0
- package/dist/agent/channels/slack-front.d.ts +38 -46
- package/dist/agent/channels/slack-front.d.ts.map +1 -1
- package/dist/agent/channels/slack-front.js +230 -23
- package/dist/agent/channels/slack-front.js.map +1 -1
- package/dist/agent/channels/slack-home.d.ts.map +1 -1
- package/dist/agent/channels/slack-home.js +10 -3
- package/dist/agent/channels/slack-home.js.map +1 -1
- package/dist/agent/channels/slack-identity.d.ts +7 -21
- package/dist/agent/channels/slack-identity.d.ts.map +1 -1
- package/dist/agent/channels/slack-identity.js +3 -1
- package/dist/agent/channels/slack-identity.js.map +1 -1
- package/dist/agent/channels/slack-memory-canvas.d.ts +21 -5
- package/dist/agent/channels/slack-memory-canvas.d.ts.map +1 -1
- package/dist/agent/channels/slack-memory-canvas.js +166 -55
- package/dist/agent/channels/slack-memory-canvas.js.map +1 -1
- package/dist/agent/channels/slack-origin-codec.d.ts.map +1 -1
- package/dist/agent/channels/slack-origin-codec.js +10 -0
- package/dist/agent/channels/slack-origin-codec.js.map +1 -1
- package/dist/agent/channels/slack-post-arm.d.ts +21 -1
- package/dist/agent/channels/slack-post-arm.d.ts.map +1 -1
- package/dist/agent/channels/slack-post-arm.js +57 -10
- package/dist/agent/channels/slack-post-arm.js.map +1 -1
- package/dist/agent/channels/slack-read.d.ts +11 -1
- package/dist/agent/channels/slack-read.d.ts.map +1 -1
- package/dist/agent/channels/slack-read.js +27 -3
- package/dist/agent/channels/slack-read.js.map +1 -1
- package/dist/agent/channels/slack-reply-blocks.d.ts +29 -0
- package/dist/agent/channels/slack-reply-blocks.d.ts.map +1 -0
- package/dist/agent/channels/slack-reply-blocks.js +172 -0
- package/dist/agent/channels/slack-reply-blocks.js.map +1 -0
- package/dist/agent/channels/slack-reply-feedback.d.ts +23 -0
- package/dist/agent/channels/slack-reply-feedback.d.ts.map +1 -0
- package/dist/agent/channels/slack-reply-feedback.js +63 -0
- package/dist/agent/channels/slack-reply-feedback.js.map +1 -0
- package/dist/agent/channels/slack-shortcut.d.ts +10 -0
- package/dist/agent/channels/slack-shortcut.d.ts.map +1 -1
- package/dist/agent/channels/slack-shortcut.js +1 -1
- package/dist/agent/channels/slack-shortcut.js.map +1 -1
- package/dist/agent/channels/slack-voice-clips.d.ts +16 -0
- package/dist/agent/channels/slack-voice-clips.d.ts.map +1 -0
- package/dist/agent/channels/slack-voice-clips.js +80 -0
- package/dist/agent/channels/slack-voice-clips.js.map +1 -0
- package/dist/agent/channels/slack-wake-backscroll.d.ts +0 -2
- package/dist/agent/channels/slack-wake-backscroll.d.ts.map +1 -1
- package/dist/agent/channels/slack-wake-backscroll.js +0 -4
- package/dist/agent/channels/slack-wake-backscroll.js.map +1 -1
- package/dist/agent/channels/slack-web-api.d.ts.map +1 -1
- package/dist/agent/channels/slack-web-api.js +4 -2
- package/dist/agent/channels/slack-web-api.js.map +1 -1
- package/dist/agent/channels/slack.d.ts +22 -1
- package/dist/agent/channels/slack.d.ts.map +1 -1
- package/dist/agent/channels/slack.js +70 -16
- package/dist/agent/channels/slack.js.map +1 -1
- package/dist/agent/docs-generated.d.ts.map +1 -1
- package/dist/agent/docs-generated.js +1282 -2
- package/dist/agent/docs-generated.js.map +1 -1
- package/dist/agent/ops.d.ts +1 -1
- package/dist/agent/ops.d.ts.map +1 -1
- package/dist/agent/ops.js +15 -21
- package/dist/agent/ops.js.map +1 -1
- package/dist/agent/register.d.ts.map +1 -1
- package/dist/agent/register.js +22 -1
- package/dist/agent/register.js.map +1 -1
- package/dist/agent/setup/credentials.d.ts +12 -0
- package/dist/agent/setup/credentials.d.ts.map +1 -1
- package/dist/agent/setup/credentials.js +17 -0
- package/dist/agent/setup/credentials.js.map +1 -1
- package/dist/agent/setup/page.d.ts.map +1 -1
- package/dist/agent/setup/page.js +6 -3
- package/dist/agent/setup/page.js.map +1 -1
- package/dist/agent/surface-parity.d.ts.map +1 -1
- package/dist/agent/surface-parity.js +2 -0
- package/dist/agent/surface-parity.js.map +1 -1
- package/package.json +40 -3
|
@@ -39,6 +39,7 @@
|
|
|
39
39
|
// commands) still forwards exactly as before.
|
|
40
40
|
// 4. Idempotency + per-thread FIFO guard the wake (see EventDedupe / ThreadFifo below).
|
|
41
41
|
|
|
42
|
+
import { auditErrorDetail } from "@open-everywhere/core/agent/adapter-api.js";
|
|
42
43
|
import { parseSlackUserDeactivation, routeSlackUserDeactivation, type SlackUserDeactivation } from "./slack-offboarding.js";
|
|
43
44
|
import type { OffboardOutcome } from "@open-everywhere/core/agent/adapter-api.js";
|
|
44
45
|
import { POST, type Channel, type HttpRouteDefinition, type RouteHandlerArgs } from "eve/channels";
|
|
@@ -50,6 +51,7 @@ import {
|
|
|
50
51
|
auditDmBotAuthorDrop,
|
|
51
52
|
auditDmDriverAdmitted,
|
|
52
53
|
dmDoorAdmitsDriver,
|
|
54
|
+
UNCHANGED_EDIT_DROP_REASON,
|
|
53
55
|
parseMessageEvent,
|
|
54
56
|
routeMessageEvent,
|
|
55
57
|
liveDeps,
|
|
@@ -67,6 +69,7 @@ import {
|
|
|
67
69
|
bindableFor,
|
|
68
70
|
bindableForJoin,
|
|
69
71
|
bindableForReaction,
|
|
72
|
+
bindableForSlackIds,
|
|
70
73
|
slackSingleWindowMentionPolicy,
|
|
71
74
|
slackDomainUsesUnifiedWindow,
|
|
72
75
|
slackFrontTenantFenceDeclined,
|
|
@@ -84,6 +87,7 @@ import {
|
|
|
84
87
|
touchDmSeat as liveTouchDmSeat,
|
|
85
88
|
dmSeatThresholds,
|
|
86
89
|
resolvePartition,
|
|
90
|
+
auditSurfaceModeFrom,
|
|
87
91
|
domainKeyOf,
|
|
88
92
|
isThreadEngaged,
|
|
89
93
|
divertInboundToUnifiedWindow,
|
|
@@ -125,8 +129,14 @@ import {
|
|
|
125
129
|
PERSON_OPT_OUT_SHORTCUT_NOTICE,
|
|
126
130
|
PERSON_OPT_OUT_UNREADABLE_NOTICE,
|
|
127
131
|
type PersonOptOutState,
|
|
132
|
+
admitAskedBotReply,
|
|
133
|
+
deploymentOrg,
|
|
128
134
|
} from "@open-everywhere/core/agent/adapter-api.js";
|
|
135
|
+
import { botReplyCandidate, slackBotReplyFacts } from "./slack-bot-ask.js";
|
|
129
136
|
import { sidePanelViewedRoom, slackDmSystemNote } from "./slack-side-panel.js";
|
|
137
|
+
import { parseAgentSessionStopped, routeAgentSessionStop, setSlackSessionStatus, slackSessionTitle } from "./slack-agent-session.js";
|
|
138
|
+
import { parseSlackFeedbackClick, routeSlackFeedbackClick, type ParsedSlackFeedbackClick } from "./slack-reply-feedback.js";
|
|
139
|
+
import { followUpMessageEvent, followUpPressKey, markFollowUpPressed, parseSlackFollowUpClick, type ParsedSlackFollowUpClick } from "./slack-follow-ups.js";
|
|
130
140
|
import {
|
|
131
141
|
renderWakeMessage,
|
|
132
142
|
slackChannelFactsFor,
|
|
@@ -138,7 +148,6 @@ import {
|
|
|
138
148
|
stripUnfencedSlackAttachments,
|
|
139
149
|
slackEnterpriseIdFromEnvelope,
|
|
140
150
|
slackForm,
|
|
141
|
-
_resetAuthorMemberCache,
|
|
142
151
|
} from "./slack-events.js";
|
|
143
152
|
import { buildSlackAuth, withForwardedSlackEnvelope, type ForwardedSlackEnvelope } from "./slack-identity.js";
|
|
144
153
|
import { holdRouteWork, routeHoldQuiesced, withSlackRouteHold } from "./slack-route-hold.js";
|
|
@@ -156,6 +165,7 @@ import {
|
|
|
156
165
|
type ParsedHomeSubmission,
|
|
157
166
|
} from "./slack-home.js";
|
|
158
167
|
import { persistSlackAttachment, registerSlackAttachment } from "./slack-attachments.js";
|
|
168
|
+
import { hasSlackVoiceClip, withSlackVoiceClips } from "./slack-voice-clips.js";
|
|
159
169
|
import {
|
|
160
170
|
parseSlackShortcutPayload,
|
|
161
171
|
shortcutMessageEvent,
|
|
@@ -199,6 +209,12 @@ export type FrontDecision =
|
|
|
199
209
|
* OWNER of the ask answered it in their own DM. Core verifies the clicker IS that owner, then
|
|
200
210
|
* approves or declines. Never forwarded. */
|
|
201
211
|
| { readonly kind: "grant-click"; readonly reason: string; readonly payload: unknown }
|
|
212
|
+
/** A click on Slack's native thumbs up / down under an agent answer (OPE-2520, slack-reply-blocks.ts)
|
|
213
|
+
* โ ours: record it as a `reply.feedback` row. Never forwarded, and it wakes nothing. */
|
|
214
|
+
| { readonly kind: "feedback-click"; readonly reason: string; readonly payload: unknown; readonly click: ParsedSlackFeedbackClick }
|
|
215
|
+
/** OPE-2516 โ a press on a suggested follow-up under an answer in a 1:1 DM (`oe_followup:<n>`). The
|
|
216
|
+
* handler wakes the DM seat with the button's text as the person's next message. */
|
|
217
|
+
| { readonly kind: "followup-click"; readonly reason: string; readonly payload: unknown; readonly click: ParsedSlackFollowUpClick }
|
|
202
218
|
/** An `app_mention` โ the native slackChannel path owns it UNLESS the handler's single-window
|
|
203
219
|
* policy says the unified window does (then the forward is suppressed and the mention's
|
|
204
220
|
* `message.channels` sibling carries it as an addressed wake). Carries `env` so the handler
|
|
@@ -228,7 +244,10 @@ export type FrontDecision =
|
|
|
228
244
|
| { readonly kind: "home-submit"; readonly reason: string; readonly submission: ParsedHomeSubmission }
|
|
229
245
|
/** An `app_context_changed` (OPE-2457) โ ours, and nothing to do. The subscription exists only so
|
|
230
246
|
* Slack attaches the viewed room to each side-panel `message.im`; the change itself wakes nothing. */
|
|
231
|
-
| { readonly kind: "context-change"; readonly reason: string }
|
|
247
|
+
| { readonly kind: "context-change"; readonly reason: string }
|
|
248
|
+
/** An `agent_session_stopped` (OPE-2519) โ ours: the person pressed Slack's native Stop on an agent
|
|
249
|
+
* session. It takes the โ arm's cancel path (slack-agent-session.ts `routeAgentSessionStop`). */
|
|
250
|
+
| { readonly kind: "session-stop"; readonly reason: string; readonly env: SlackEventEnvelope };
|
|
232
251
|
|
|
233
252
|
/**
|
|
234
253
|
* Best-effort decode of a urlencoded interactivity body into an `oe_approval:` block_actions
|
|
@@ -402,6 +421,19 @@ export function classifyEnvelope(rawBody: string, contentType: string): FrontDec
|
|
|
402
421
|
if (grantClick !== undefined) {
|
|
403
422
|
return { kind: "grant-click", reason: "block_actions oe_grant: click โ ours, never forwarded", payload: grantClick };
|
|
404
423
|
}
|
|
424
|
+
// OPE-2520 โ the native thumbs under an answer. Its action id is exactly `oe_feedback`, which no
|
|
425
|
+
// prefix above matches, so the order among these branches does not matter.
|
|
426
|
+
const feedbackPayload = decodeInteractivityPayload(rawBody);
|
|
427
|
+
const feedbackClick = parseSlackFeedbackClick(feedbackPayload);
|
|
428
|
+
if (feedbackClick !== undefined) {
|
|
429
|
+
return { kind: "feedback-click", reason: `block_actions oe_feedback ${feedbackClick.rating} โ ours, never forwarded`, payload: feedbackPayload, click: feedbackClick };
|
|
430
|
+
}
|
|
431
|
+
// OPE-2516 โ a suggested follow-up under an answer. Its action ids start `oe_followup:`, which no
|
|
432
|
+
// branch above matches.
|
|
433
|
+
const followUpClick = parseSlackFollowUpClick(feedbackPayload);
|
|
434
|
+
if (followUpClick !== undefined) {
|
|
435
|
+
return { kind: "followup-click", reason: "block_actions oe_followup: press in a DM โ ours, never forwarded", payload: feedbackPayload, click: followUpClick };
|
|
436
|
+
}
|
|
405
437
|
// THE HOME TAB (OPE-2451). Its clicks carry an `oe_home:` action id and its one modal a callback
|
|
406
438
|
// id of its own, so neither can be mistaken for another door's payload, nor for eve's.
|
|
407
439
|
const homePayload = decodeInteractivityPayload(rawBody);
|
|
@@ -462,6 +494,10 @@ export function classifyEnvelope(rawBody: string, contentType: string): FrontDec
|
|
|
462
494
|
// forward costs eve a parse and a re-verify for every click the person makes in Slack.
|
|
463
495
|
return { kind: "context-change", reason: "app_context_changed โ the room rides the next side-panel message; nothing to wake" };
|
|
464
496
|
}
|
|
497
|
+
if (eventType === "agent_session_stopped") {
|
|
498
|
+
// SLACK'S NATIVE STOP (OPE-2519). The subscription is what makes Slack show the button at all.
|
|
499
|
+
return { kind: "session-stop", reason: "agent_session_stopped โ stop the session's running turn", env };
|
|
500
|
+
}
|
|
465
501
|
if (eventType === "user_change") {
|
|
466
502
|
// OPE-2423: slackChannel drops it, and a DEACTIVATED user is ours โ automatic offboarding.
|
|
467
503
|
return { kind: "user-change", reason: "user_change โ a deactivation report candidate", env };
|
|
@@ -575,16 +611,14 @@ export function resolveBotIdentity(): Promise<{ readonly userId?: string; readon
|
|
|
575
611
|
return botIdentityPromise;
|
|
576
612
|
}
|
|
577
613
|
|
|
578
|
-
/** Reset the cached identity (tests only). */
|
|
579
|
-
export function _resetBotIdentity(): void {
|
|
580
|
-
botIdentityPromise = undefined;
|
|
581
|
-
}
|
|
582
|
-
|
|
583
614
|
// --- the composed channel ---------------------------------------------------
|
|
584
615
|
|
|
585
616
|
/** Injectable seams so the wake path is testable without live crypto / Slack. */
|
|
586
617
|
export interface FrontOptions {
|
|
587
618
|
readonly signingSecret?: string;
|
|
619
|
+
/** Hear the voice clips on a message an addressed road already decided to wake (OPE-2524). Defaults to
|
|
620
|
+
* slack-voice-clips.ts `withSlackVoiceClips`; a test injects a stub so no audio is fetched. */
|
|
621
|
+
readonly hearVoiceClips?: (ev: ParsedMessageEvent) => Promise<ParsedMessageEvent>;
|
|
588
622
|
readonly dedupe?: EventDedupe;
|
|
589
623
|
readonly fifo?: ThreadFifo;
|
|
590
624
|
readonly getBotIdentity?: () => Promise<{ readonly userId?: string; readonly botId?: string }>;
|
|
@@ -634,6 +668,10 @@ export interface FrontOptions {
|
|
|
634
668
|
readonly handleHomeOpened?: (open: ParsedHomeOpen) => Promise<boolean>;
|
|
635
669
|
readonly handleHomeClick?: (click: ParsedHomeClick) => Promise<boolean>;
|
|
636
670
|
readonly handleHomeSubmission?: (submission: ParsedHomeSubmission) => Promise<boolean>;
|
|
671
|
+
/** OPE-2426 โ the asked-thread exception for a bot-authored thread reply. Returns the relay to run (the
|
|
672
|
+
* reply goes to the asker's DM, never to this channel's seat), or null to fall through to the ordinary
|
|
673
|
+
* bot-author drop. Injected in tests; the live default is core's `admitAskedBotReply`. */
|
|
674
|
+
readonly admitBotReply?: (ev: ParsedMessageEvent, self: { readonly userId?: string; readonly botId?: string }) => Promise<{ readonly relay: () => Promise<unknown> } | null>;
|
|
637
675
|
/** The domain memory canvas recheck a trust event runs (OPE-2451): a person joined the channel, or it
|
|
638
676
|
* was shared. Injected in tests; the live default is slack-memory-canvas.ts's, over the live deps. */
|
|
639
677
|
readonly recheckChannelCanvas?: (input: { org: string; teamId: string; channelId: string; trigger: "member-joined" | "channel-shared"; joinedUserId?: string }) => Promise<unknown>;
|
|
@@ -843,7 +881,7 @@ const logOutcome =
|
|
|
843
881
|
* ({@link markUndeliveredAfterHeal}) before it rethrows, so {@link recordWakeFailure} can give the
|
|
844
882
|
* row its own reason.
|
|
845
883
|
*/
|
|
846
|
-
|
|
884
|
+
async function deliverWithStrandRetry<T>(deliver: () => Promise<T>): Promise<T> {
|
|
847
885
|
try {
|
|
848
886
|
return await deliver();
|
|
849
887
|
} catch (err) {
|
|
@@ -871,10 +909,11 @@ export function recordWakeFailure(key: string, err: unknown): void {
|
|
|
871
909
|
void holdRouteWork(
|
|
872
910
|
(async () => {
|
|
873
911
|
try {
|
|
874
|
-
const
|
|
912
|
+
const bindable = {
|
|
875
913
|
channel: { kind: "slack" },
|
|
876
914
|
session: { auth: { current: { attributes: { team_id: "", channel_id: channelId }, principalId: "", principalType: "slack-user" } } },
|
|
877
|
-
} as Parameters<typeof resolvePartition>[0]
|
|
915
|
+
} as Parameters<typeof resolvePartition>[0];
|
|
916
|
+
const p = resolvePartition(bindable);
|
|
878
917
|
// OPE-970: a retry that ALSO failed after the pruned-deployment heal gets its own reason โ
|
|
879
918
|
// otherwise it is indistinguishable from any other send failure, and the only way to tell
|
|
880
919
|
// "the seat is fine now but this message was lost" from "a transient blip" was to read the
|
|
@@ -886,10 +925,11 @@ export function recordWakeFailure(key: string, err: unknown): void {
|
|
|
886
925
|
platform: p.platform,
|
|
887
926
|
workspace: p.workspace,
|
|
888
927
|
surface: p.surface,
|
|
928
|
+
...auditSurfaceModeFrom(bindable),
|
|
889
929
|
action: "surface.wake_failed",
|
|
890
930
|
detail: {
|
|
891
931
|
fifoKey: key,
|
|
892
|
-
message:
|
|
932
|
+
message: auditErrorDetail(err).error,
|
|
893
933
|
...(undeliveredAfterHeal
|
|
894
934
|
? { reason: "pruned-deployment-heal-retry-failed", remedy: "seat healed for its next wake; this message got no answer" }
|
|
895
935
|
: {}),
|
|
@@ -991,11 +1031,6 @@ async function liveDmPartnerTrust(userId: string, teamId: string | undefined): P
|
|
|
991
1031
|
return member === undefined ? undefined : member ? "internal" : "external";
|
|
992
1032
|
}
|
|
993
1033
|
|
|
994
|
-
/** Reset the cached DM-partner trust (tests only). */
|
|
995
|
-
export function _resetDmTrustCache(): void {
|
|
996
|
-
_resetAuthorMemberCache();
|
|
997
|
-
}
|
|
998
|
-
|
|
999
1034
|
// `wrapDmSeatFrom` (OPE-90) is DELETED at OPE-351 stage 2. It existed to remap the address of eve's
|
|
1000
1035
|
// NATIVE DM send onto a rotatable seat token โ a bolt-on to a dispatch path a DM no longer takes. The
|
|
1001
1036
|
// DM is now diverted onto the `unified` channel before the forward ever happens (`scheduleDmWake`
|
|
@@ -1051,9 +1086,27 @@ export function mentionSiblingSuppressed(input: {
|
|
|
1051
1086
|
return input.authoredText.includes(`<@${input.botUserId}>`);
|
|
1052
1087
|
}
|
|
1053
1088
|
|
|
1089
|
+
/**
|
|
1090
|
+
* Would a TYPED message in this thread reach the agent (OPE-2524)? The thread roads hear a voice clip only then, so a clip
|
|
1091
|
+
* is handled exactly as the same words typed. That is an engagement row that was not stood down: an invited thread, or a
|
|
1092
|
+
* name-drop watcher thread (whose typed follow-ups `shouldDeliverReply` admits, and which wakes with `oeUninvited`). A
|
|
1093
|
+
* STOOD-DOWN row is the one case `isThreadEngaged` admits and the road then drops (`shouldDeliverReply`'s `stopped`
|
|
1094
|
+
* leg, `routeSupervisorEvent`'s stood-down silence), so a clip heard there would be downloaded and billed for nothing.
|
|
1095
|
+
* Fail-closed: an unreadable row is not heard.
|
|
1096
|
+
*/
|
|
1097
|
+
async function threadHearsClips(p: { readonly org: string; readonly surfaceKey: string }, threadTs: string): Promise<boolean> {
|
|
1098
|
+
try {
|
|
1099
|
+
const row = await getOperationalStore().getEngagement(p.org, p.surfaceKey, threadTs);
|
|
1100
|
+
return row !== null && row.stopped !== true;
|
|
1101
|
+
} catch {
|
|
1102
|
+
return false;
|
|
1103
|
+
}
|
|
1104
|
+
}
|
|
1105
|
+
|
|
1054
1106
|
export function buildFrontHandler(innerHandler: RouteHandler, opts: FrontOptions = {}): RouteHandler {
|
|
1055
1107
|
const dedupe = opts.dedupe ?? new EventDedupe();
|
|
1056
1108
|
const fifo = opts.fifo ?? new ThreadFifo(recordWakeFailure);
|
|
1109
|
+
const hearVoice = opts.hearVoiceClips ?? ((ev: ParsedMessageEvent) => withSlackVoiceClips(ev));
|
|
1057
1110
|
const getBotIdentity = opts.getBotIdentity ?? resolveBotIdentity;
|
|
1058
1111
|
const now = opts.now ?? (() => Date.now());
|
|
1059
1112
|
// THE SIGNING SECRET RESOLVES PER REQUEST, AND IT IS ASYNC SINCE OPE-2231. It used to be a
|
|
@@ -1085,6 +1138,7 @@ export function buildFrontHandler(innerHandler: RouteHandler, opts: FrontOptions
|
|
|
1085
1138
|
const homeClicked = opts.handleHomeClick ?? ((click: ParsedHomeClick) => handleHomeClick(click, liveSlackHomeDeps()));
|
|
1086
1139
|
const recheckCanvas = opts.recheckChannelCanvas ?? ((input: { org: string; teamId: string; channelId: string; trigger: "member-joined" | "channel-shared"; joinedUserId?: string }) => recheckChannelCanvas(input, liveMemoryCanvasDeps()));
|
|
1087
1140
|
const homeSubmitted = opts.handleHomeSubmission ?? ((submission: ParsedHomeSubmission) => handleHomeSubmission(submission, liveSlackHomeDeps()));
|
|
1141
|
+
const admitBotReply = opts.admitBotReply ?? ((ev: ParsedMessageEvent, self: { readonly userId?: string; readonly botId?: string }) => admitAskedBotReply(slackBotReplyFacts(ev, deploymentOrg(), self), ev.authoredText));
|
|
1088
1142
|
const ambientEnabledFor = opts.ambientEnabledFor ?? ((surfaceKey: string) => ambientSupervisorEnabled(surfaceKey, getConfig().loadDomains()));
|
|
1089
1143
|
const seatedSurface = opts.seatedSurfaceFor ?? slackDomainUsesUnifiedWindow;
|
|
1090
1144
|
const mentionPolicyFor = opts.mentionPolicyFor ?? slackSingleWindowMentionPolicy;
|
|
@@ -1270,7 +1324,11 @@ export function buildFrontHandler(innerHandler: RouteHandler, opts: FrontOptions
|
|
|
1270
1324
|
// `onWake` runs SYNCHRONOUSLY, once, immediately before a wake is scheduled โ and never on the
|
|
1271
1325
|
// addressed-drop path below. The shortcut route (OPE-1442) uses it to seed the attachment
|
|
1272
1326
|
// registry only for a click that actually wakes something; every other caller omits it.
|
|
1273
|
-
|
|
1327
|
+
// `hearsMentionClip` (OPE-2524) is an explicit opt-in, set by the diverted @mention road ONLY. `directMention` alone
|
|
1328
|
+
// is not enough: a channel MESSAGE SHORTCUT is also an addressed wake, but the clip on its target message is someone
|
|
1329
|
+
// ELSE's speech, which the shortcut fences as untrusted, and hearing it would put that person's words outside the
|
|
1330
|
+
// fence at the presser's trust. A shortcut's clip is not heard.
|
|
1331
|
+
const scheduleUnownedWake = (ev: ParsedMessageEvent, label: string, directMention = false, onWake?: () => void, seatEngaged = false, hearsMentionClip = false): void => {
|
|
1274
1332
|
let ambient = false;
|
|
1275
1333
|
let surfaceKey: string | undefined;
|
|
1276
1334
|
try {
|
|
@@ -1319,15 +1377,31 @@ export function buildFrontHandler(innerHandler: RouteHandler, opts: FrontOptions
|
|
|
1319
1377
|
// on the hot path exactly like the token-based key did.
|
|
1320
1378
|
const supKey = supervisorFifoKey(ev.channelId);
|
|
1321
1379
|
args.waitUntil(
|
|
1322
|
-
fifo.enqueue(supKey, () =>
|
|
1323
|
-
|
|
1380
|
+
fifo.enqueue(supKey, async () => {
|
|
1381
|
+
// A voice clip is heard on an ADDRESSED seat wake only โ a mention, or an engaged thread that was not stood
|
|
1382
|
+
// down. An ambient wake on unaddressed talk never downloads a room's audio (OPE-2524, slack-voice-clips.ts).
|
|
1383
|
+
// A bot author is dropped by the seat's own gate, so its clip is never paid for. The clip test comes first, so
|
|
1384
|
+
// a message with no clip pays no extra store read. The thread leg also asks the room's fail-closed internal
|
|
1385
|
+
// confirmation FIRST, the one `shouldSupervisorWake` drops on later: a thread in a room that became shared, or
|
|
1386
|
+
// cannot be confirmed internal, wakes nothing, so its clip must not reach speech-to-text. (A mention already
|
|
1387
|
+
// passed that confirmation in the single-window policy.)
|
|
1388
|
+
const hears =
|
|
1389
|
+
!ev.botId &&
|
|
1390
|
+
hasSlackVoiceClip(ev) &&
|
|
1391
|
+
((directMention && hearsMentionClip) ||
|
|
1392
|
+
(seatEngaged &&
|
|
1393
|
+
ev.threadTs !== undefined &&
|
|
1394
|
+
(await channelFactsFor(ev.channelId).then((f) => f.internal === true, () => false)) &&
|
|
1395
|
+
(await threadHearsClips(resolvePartition(bindableFor(ev)), ev.threadTs))));
|
|
1396
|
+
const heard = hears ? await hearVoice(ev) : ev;
|
|
1397
|
+
return deliverWithStrandRetry(() =>
|
|
1324
1398
|
routeSupervisorEvent(
|
|
1325
|
-
|
|
1399
|
+
heard,
|
|
1326
1400
|
withSingleWindowDiversion(liveSupervisorDeps(send), args.to, surfaceKey!),
|
|
1327
1401
|
directMention ? { directMention: true } : undefined,
|
|
1328
1402
|
).then(logOutcome(`${label}-supervisor`, ev.channelId, ev.ts)),
|
|
1329
|
-
)
|
|
1330
|
-
),
|
|
1403
|
+
);
|
|
1404
|
+
}),
|
|
1331
1405
|
);
|
|
1332
1406
|
return;
|
|
1333
1407
|
}
|
|
@@ -1420,7 +1494,15 @@ export function buildFrontHandler(innerHandler: RouteHandler, opts: FrontOptions
|
|
|
1420
1494
|
/** The DM door admitted this bot author as the deployment's named e2e driver (OPE-1023).
|
|
1421
1495
|
* Defaults FALSE, so every existing call site is unchanged. */
|
|
1422
1496
|
driverAdmitted = false,
|
|
1497
|
+
/** Set only for a real `message.im` the person sent, which belongs to a Slack agent session
|
|
1498
|
+
* (OPE-2519): the words the person TYPED, before any voice clip was heard. A shortcut's minted
|
|
1499
|
+
* ts names no message, and a group DM has no agent session, so both leave it undefined and
|
|
1500
|
+
* leave the session untouched. The title reads only these words: a voice clip's transcript
|
|
1501
|
+
* rides `authoredText` behind a `[voice clip]` marker, and a clip OE could not hear adds a
|
|
1502
|
+
* host-written line, so neither is the person's typed text. */
|
|
1503
|
+
agentSessionTypedText?: string,
|
|
1423
1504
|
): Promise<void> => {
|
|
1505
|
+
const opensAgentSession = agentSessionTypedText !== undefined;
|
|
1424
1506
|
// ๐ด THE TENANT FENCE, AND THIS IS ITS CHOKEPOINT FOR THE WHOLE DM FAMILY (OPE-1644). Before
|
|
1425
1507
|
// this line `slack-front.ts` consulted the fence NOWHERE, while the mention door, the four
|
|
1426
1508
|
// dispatching engagement routes, the channel-join door and the `/oe` slash door all did โ so a
|
|
@@ -1575,6 +1657,8 @@ export function buildFrontHandler(innerHandler: RouteHandler, opts: FrontOptions
|
|
|
1575
1657
|
channelType: dmKind,
|
|
1576
1658
|
isPrivate: true,
|
|
1577
1659
|
...(partnerTrust ? { dmPartnerTrust: partnerTrust } : {}),
|
|
1660
|
+
// OPE-2519: the 1:1 message's Slack agent session โ its thread, or itself when top-level.
|
|
1661
|
+
...(opensAgentSession && !isGroup ? { agentSessionTs: ev.threadTs ?? ev.ts } : {}),
|
|
1578
1662
|
// ๐ด THE GROUP DM CARRIES THE PLATFORM'S OWN SHARING VERDICT (OPE-1486), which is what a
|
|
1579
1663
|
// channel wake has always stamped (`authForEvent`) and an mpim wake never did. `bindSlack`
|
|
1580
1664
|
// reads the pair through `slackTrust`, so this is the binding's only route to the fact
|
|
@@ -1630,6 +1714,18 @@ export function buildFrontHandler(innerHandler: RouteHandler, opts: FrontOptions
|
|
|
1630
1714
|
...(seat.freshGeneration && seat.tail.length > 0 ? { backscroll: seat.tail } : {}),
|
|
1631
1715
|
});
|
|
1632
1716
|
|
|
1717
|
+
// THE SESSION TITLE (OPE-2519). A top-level 1:1 message opens a new conversation in the Messages
|
|
1718
|
+
// tab or the side panel, and Slack keys its agent session by that message. Create the session with
|
|
1719
|
+
// a title from the person's own words BEFORE the wake: the turn's `processing` call would
|
|
1720
|
+
// otherwise create it untitled first, and Slack applies a title only on creation. That same rule
|
|
1721
|
+
// keeps a title the person typed over ours. A group DM, a shortcut and a thread reply open no new
|
|
1722
|
+
// session. The title reads the TYPED text alone, before any voice clip was heard: `text` also
|
|
1723
|
+
// carries the lines of a forwarded or quoted message, which are another conversation's words.
|
|
1724
|
+
if (opensAgentSession && !isGroup && !ev.threadTs && ev.ts) {
|
|
1725
|
+
const title = slackSessionTitle(agentSessionTypedText);
|
|
1726
|
+
if (title) await setSlackSessionStatus({ channelId: ev.channelId, threadTs: ev.ts, status: "active", title });
|
|
1727
|
+
}
|
|
1728
|
+
|
|
1633
1729
|
// ๐ด NO `replyAnchorTs` ON A TOP-LEVEL DM โ the reply stays FLAT in the conversation. The
|
|
1634
1730
|
// CHANNEL divert DOES set one (slack-events.ts), because threading an answer under its trigger
|
|
1635
1731
|
// keeps a busy shared channel tidy; the slack post arm reads `threadTs ?? replyAnchorTs`
|
|
@@ -1773,6 +1869,16 @@ export function buildFrontHandler(innerHandler: RouteHandler, opts: FrontOptions
|
|
|
1773
1869
|
// between here and it. The parse above has already seeded the in-memory attachment LRU, which
|
|
1774
1870
|
// is the one cost no placement in this branch avoids.
|
|
1775
1871
|
if (slackFrontTenantFenceDeclined(dev, `dm-${decision.dmKind}`, "front-dm-branch")) return fencedAck(OK());
|
|
1872
|
+
// ๐ด AN EDIT THAT CHANGED NOTHING IS NOT A WAKE (OPE-2577). OE's own agent-session status calls
|
|
1873
|
+
// edit the person's root message, and each edit reaches this door as a `message_changed` with
|
|
1874
|
+
// the person as author. Woken, each one was a new inbound wake that made the answer to the
|
|
1875
|
+
// person's real message stale. See {@link isUnchangedSlackEdit}. Above the driver carve-out, so
|
|
1876
|
+
// the driver's DM and a person's DM take the same drop. A log line only, no audit row: three of
|
|
1877
|
+
// these follow every DM turn, the same reasoning that keeps our own reply echo out of the trail.
|
|
1878
|
+
if (dev.unchangedEdit) {
|
|
1879
|
+
console.log(`[open-everywhere:slack-front] ${decision.dmKind} ${dev.channelId}/${dev.ts}: no wake โ ${UNCHANGED_EDIT_DROP_REASON}`);
|
|
1880
|
+
return OK();
|
|
1881
|
+
}
|
|
1776
1882
|
// A bot-authored DM is never a wake โ a no-op 200. NOT a forward: forwarding a bot_message
|
|
1777
1883
|
// would hand eve's native door a turn the DM window is meant to own.
|
|
1778
1884
|
//
|
|
@@ -1871,7 +1977,10 @@ export function buildFrontHandler(innerHandler: RouteHandler, opts: FrontOptions
|
|
|
1871
1977
|
// enqueues on the SAME key, so a group DM's two legs can never run concurrently on one seat.
|
|
1872
1978
|
args.waitUntil(
|
|
1873
1979
|
fifo.enqueue(dmFifoKey(dev.channelId), async () => {
|
|
1874
|
-
|
|
1980
|
+
// A voice clip is heard ONCE, before the retry wrapper, so a strand retry never pays for a second
|
|
1981
|
+
// transcription (OPE-2524, slack-voice-clips.ts). A DM is always addressed, so its clips are always heard.
|
|
1982
|
+
const heard = await hearVoice(dev);
|
|
1983
|
+
await deliverWithStrandRetry(() => scheduleDmWake(heard, decision.dmKind, driverAdmitted, decision.dmKind === "im" ? dev.authoredText : undefined));
|
|
1875
1984
|
console.log(`[open-everywhere:slack-front] ${decision.dmKind} ${dev.channelId}/${dev.ts}: woke โ diverted to the DM window seat`);
|
|
1876
1985
|
}),
|
|
1877
1986
|
);
|
|
@@ -2045,7 +2154,7 @@ export function buildFrontHandler(innerHandler: RouteHandler, opts: FrontOptions
|
|
|
2045
2154
|
// directMention path (slack-events.ts routeSupervisorEvent) and re-checks its own
|
|
2046
2155
|
// fail-closed internal confirmation โ the SAME process-local TTL-cached conversations.info
|
|
2047
2156
|
// the policy just populated, so the two reads on this one webhook are cache-coherent.
|
|
2048
|
-
scheduleUnownedWake(mev, "single-window-mention", true);
|
|
2157
|
+
scheduleUnownedWake(mev, "single-window-mention", true, undefined, false, true);
|
|
2049
2158
|
return OK();
|
|
2050
2159
|
}
|
|
2051
2160
|
// ๐ด THE GROUP-DM MENTION LEG (OPE-351 stage 4). An @mention is how a human addresses the bot in
|
|
@@ -2084,7 +2193,9 @@ export function buildFrontHandler(innerHandler: RouteHandler, opts: FrontOptions
|
|
|
2084
2193
|
// which is what makes `bindSlack` bind mode `mpim` on this leg exactly as on the other.
|
|
2085
2194
|
args.waitUntil(
|
|
2086
2195
|
fifo.enqueue(dmFifoKey(mev.channelId), async () => {
|
|
2087
|
-
|
|
2196
|
+
// A bot's group-DM mention is not heard: `scheduleDmWake` drops a bot author after this step.
|
|
2197
|
+
const heard = mev.botId ? mev : await hearVoice(mev);
|
|
2198
|
+
await deliverWithStrandRetry(() => scheduleDmWake({ ...heard, channelType: "mpim" }, "mpim"));
|
|
2088
2199
|
}),
|
|
2089
2200
|
);
|
|
2090
2201
|
return OK();
|
|
@@ -2240,6 +2351,56 @@ export function buildFrontHandler(innerHandler: RouteHandler, opts: FrontOptions
|
|
|
2240
2351
|
return new Response(null, { status: 200 });
|
|
2241
2352
|
}
|
|
2242
2353
|
|
|
2354
|
+
if (decision.kind === "feedback-click") {
|
|
2355
|
+
// THE TENANT FENCE FIRST (OPE-1680), as every click branch: a foreign workspace writes no row.
|
|
2356
|
+
if (slackInteractivityTenantFenceDeclined(interactivityFenceIds(decision.payload), "feedback-click", "front-interactivity-branch")) {
|
|
2357
|
+
return fencedAck(new Response(null, { status: 200 }));
|
|
2358
|
+
}
|
|
2359
|
+
// OPE-2520 โ a rating, recorded after the acknowledgement. Slack draws the pressed state itself,
|
|
2360
|
+
// so the empty 200 is the whole answer, and the row write never holds the click up.
|
|
2361
|
+
const click = decision.click;
|
|
2362
|
+
args.waitUntil(
|
|
2363
|
+
routeSlackFeedbackClick(click)
|
|
2364
|
+
.then((recorded) => {
|
|
2365
|
+
if (!recorded) console.warn("[open-everywhere:slack-front] a thumbs click was not recorded");
|
|
2366
|
+
})
|
|
2367
|
+
.catch(() => undefined),
|
|
2368
|
+
);
|
|
2369
|
+
return new Response(null, { status: 200 });
|
|
2370
|
+
}
|
|
2371
|
+
|
|
2372
|
+
if (decision.kind === "followup-click") {
|
|
2373
|
+
// OPE-2516 โ the person pressed a suggested follow-up under an answer in their DM.
|
|
2374
|
+
// THE TENANT FENCE FIRST (OPE-1680), as every click branch. `scheduleDmWake` refuses the same
|
|
2375
|
+
// workspace on its own; this spares an unadmitted one the rewrite and the dedupe write.
|
|
2376
|
+
const click = decision.click;
|
|
2377
|
+
const sev = followUpMessageEvent(click, now());
|
|
2378
|
+
if (slackInteractivityTenantFenceDeclined(interactivityFenceIds(decision.payload), "followup-click", "front-interactivity-branch")) {
|
|
2379
|
+
return fencedAck(new Response(null, { status: 200 }));
|
|
2380
|
+
}
|
|
2381
|
+
// One answer takes one press: a double press, or Slack's redelivery of one, wakes the seat once.
|
|
2382
|
+
if (!(await notYetSeenKey(followUpPressKey(click), sev))) return new Response(null, { status: 200 });
|
|
2383
|
+
// ACK FIRST, WORK AFTER, as the shortcut branch: the rewrite and the wake both ride `waitUntil`.
|
|
2384
|
+
// The rewrite removes the buttons and shows what the person sent. The wake takes the SAME road a
|
|
2385
|
+
// typed DM takes, so the seat, the trust probe, the external-participant policy and the spend
|
|
2386
|
+
// gate all decide it. The agent session is touched only when the answer sits in a thread: a
|
|
2387
|
+
// top-level answer has no session thread for a minted ts to name.
|
|
2388
|
+
args.waitUntil(
|
|
2389
|
+
markFollowUpPressed(click)
|
|
2390
|
+
.then((rewritten) => {
|
|
2391
|
+
if (!rewritten) console.log(`[open-everywhere:slack-front] follow-up press in ${click.channelId}: the answer was not rewritten`);
|
|
2392
|
+
})
|
|
2393
|
+
.catch(() => undefined),
|
|
2394
|
+
);
|
|
2395
|
+
args.waitUntil(
|
|
2396
|
+
fifo.enqueue(dmFifoKey(click.channelId), async () => {
|
|
2397
|
+
await deliverWithStrandRetry(() => scheduleDmWake(sev, "im", false, click.threadTs === undefined ? undefined : click.text));
|
|
2398
|
+
console.log(`[open-everywhere:slack-front] follow-up press in ${click.channelId}: woke the DM window`);
|
|
2399
|
+
}),
|
|
2400
|
+
);
|
|
2401
|
+
return new Response(null, { status: 200 });
|
|
2402
|
+
}
|
|
2403
|
+
|
|
2243
2404
|
if (decision.kind === "home-open" || decision.kind === "home-click" || decision.kind === "home-submit") {
|
|
2244
2405
|
// THE HOME TAB (OPE-2451). THE TENANT FENCE FIRST (OPE-1680), for the reason the approval branch
|
|
2245
2406
|
// states: the page reads and changes a person's memory, keyed to a bare per-workspace member id,
|
|
@@ -2425,6 +2586,49 @@ export function buildFrontHandler(innerHandler: RouteHandler, opts: FrontOptions
|
|
|
2425
2586
|
return OK();
|
|
2426
2587
|
}
|
|
2427
2588
|
|
|
2589
|
+
if (decision.kind === "session-stop") {
|
|
2590
|
+
// SLACK'S NATIVE STOP (OPE-2519) โ the same cancel door as the โ arm below, with the same fence
|
|
2591
|
+
// in front of it: an unadmitted workspace's press must never reach `args.attachSession`.
|
|
2592
|
+
const stop = parseAgentSessionStopped(decision.env);
|
|
2593
|
+
if (!stop) return OK();
|
|
2594
|
+
const stopEnterpriseId = slackEnterpriseIdFromEnvelope(decision.env);
|
|
2595
|
+
if (
|
|
2596
|
+
slackInteractivityTenantFenceDeclined(
|
|
2597
|
+
{
|
|
2598
|
+
...(stop.teamId === undefined ? {} : { teamId: stop.teamId }),
|
|
2599
|
+
...(stopEnterpriseId === undefined ? {} : { enterpriseId: stopEnterpriseId }),
|
|
2600
|
+
channelId: stop.channelId,
|
|
2601
|
+
userId: stop.userId,
|
|
2602
|
+
},
|
|
2603
|
+
"session-stop",
|
|
2604
|
+
"front-session-stop-branch",
|
|
2605
|
+
)
|
|
2606
|
+
) {
|
|
2607
|
+
return fencedAck(OK());
|
|
2608
|
+
}
|
|
2609
|
+
// A Slack RETRY of the same press must not send a second cancel: in-memory first, then durable.
|
|
2610
|
+
const skey = wakeKey(decision.env, stop.channelId, stop.eventTs ?? stop.threadTs);
|
|
2611
|
+
if (dedupe.seen(skey, now())) return OK();
|
|
2612
|
+
dedupe.mark(skey, now());
|
|
2613
|
+
try {
|
|
2614
|
+
const org = resolvePartition(bindableForSlackIds({ teamId: stop.teamId, channelId: stop.channelId, userId: stop.userId })).org;
|
|
2615
|
+
if (!(await markEventSeen(org, skey, new Date(now()).toISOString()))) return OK();
|
|
2616
|
+
} catch {
|
|
2617
|
+
/* unresolvable partition โ in-memory dedupe only (fail-open, as for a reaction) */
|
|
2618
|
+
}
|
|
2619
|
+
if (!args.attachSession) {
|
|
2620
|
+
console.warn(`[open-everywhere:slack-front] session stop ${stop.channelId}/${stop.threadTs}: no cancel door on this route โ nothing stopped`);
|
|
2621
|
+
return OK();
|
|
2622
|
+
}
|
|
2623
|
+
const attachSession = args.attachSession;
|
|
2624
|
+
args.waitUntil(
|
|
2625
|
+
routeAgentSessionStop(stop, { attachSession }).then((r) =>
|
|
2626
|
+
console.log(`[open-everywhere:slack-front] session stop ${stop.channelId}/${stop.threadTs}: ${r.outcome}`),
|
|
2627
|
+
),
|
|
2628
|
+
);
|
|
2629
|
+
return OK();
|
|
2630
|
+
}
|
|
2631
|
+
|
|
2428
2632
|
if (decision.kind === "reaction") {
|
|
2429
2633
|
// Inbound reaction (feedback signal). Parse it; an unusable envelope is a no-op 200.
|
|
2430
2634
|
const rev = parseSlackReactionEvent(decision.env);
|
|
@@ -2488,11 +2692,36 @@ export function buildFrontHandler(innerHandler: RouteHandler, opts: FrontOptions
|
|
|
2488
2692
|
// Wake path. Parse the message; a non-message or id-less envelope is a no-op 200.
|
|
2489
2693
|
const ev = parseMessageEvent(decision.env);
|
|
2490
2694
|
if (!ev) return OK();
|
|
2695
|
+
// An edit that changed no words and no files (a link unfurl, for example) is not news here either:
|
|
2696
|
+
// the engagement and supervisor gates keep `message_changed` so a person's REAL edit wakes, and
|
|
2697
|
+
// this keeps a metadata-only one out (OPE-2577, {@link isUnchangedSlackEdit}).
|
|
2698
|
+
if (ev.unchangedEdit) return OK();
|
|
2491
2699
|
persistAttachments(ev); // durable upload registry (task #107) โ so a delegated worker can read this message's files
|
|
2492
2700
|
|
|
2701
|
+
// ๐ด THE ASKED-THREAD EXCEPTION (OPE-2426): the only road by which another bot's message WAKES the
|
|
2702
|
+
// agent on a real deployment. (An attributable bot's earlier post can still enter a woken seat's
|
|
2703
|
+
// backscroll as context-only awareness: slack-wake-backscroll.ts `isBackscrollVisible`.) The other bot admissions are the test-bed e2e driver settings (OPE-1023):
|
|
2704
|
+
// `wake.driverAllowlist` on the engagement and mention doors, channel messages included, and
|
|
2705
|
+
// `wake.e2eDrivers` on the DM door and the seat's own reads. A real deployment leaves both empty. A bot's reply in a thread where the person's DM asked THAT bot, inside the window, is
|
|
2706
|
+
// relayed to the person's DM and stops here: it never wakes this channel's seat, and the host posts
|
|
2707
|
+
// nothing in the thread. Every other bot message โ a top-level post, a thread nobody asked it in, a
|
|
2708
|
+
// different bot, a late reply, an edit โ gets null and falls through to the ordinary bot-author drop
|
|
2709
|
+
// below, unchanged. It runs BEFORE mention suppression, so a reply that also @mentions the agent is
|
|
2710
|
+
// still relayed. Its `app_mention` twin is dropped as a bot author further down: by
|
|
2711
|
+
// `shouldSupervisorWake` on the single-window road, or by the native mention handler (slack.ts). The relay
|
|
2712
|
+
// claims each message once, so a retried event relays nothing twice.
|
|
2713
|
+
const identity = await getBotIdentity();
|
|
2714
|
+
if (botReplyCandidate(ev)) {
|
|
2715
|
+
const admitted = await admitBotReply(ev, identity).catch(() => null);
|
|
2716
|
+
if (admitted) {
|
|
2717
|
+
args.waitUntil(admitted.relay().then((route) => console.log(`[open-everywhere:slack-front] asked-thread bot reply ${ev.channelId}/${ev.threadTs}: relayed to the asker's DM (${String(route)})`)));
|
|
2718
|
+
return OK();
|
|
2719
|
+
}
|
|
2720
|
+
}
|
|
2721
|
+
|
|
2493
2722
|
// Mention-sibling suppression โ see {@link mentionSiblingSuppressed} for the whole rule and for
|
|
2494
2723
|
// why an EDIT is exempt from it.
|
|
2495
|
-
const { userId: botUserId } =
|
|
2724
|
+
const { userId: botUserId } = identity;
|
|
2496
2725
|
if (mentionSiblingSuppressed({ authoredText: ev.authoredText, ...(ev.subtype === undefined ? {} : { subtype: ev.subtype }), ...(botUserId === undefined ? {} : { botUserId }) })) {
|
|
2497
2726
|
return OK();
|
|
2498
2727
|
}
|
|
@@ -2555,7 +2784,13 @@ export function buildFrontHandler(innerHandler: RouteHandler, opts: FrontOptions
|
|
|
2555
2784
|
// problem the durable dedupe above closes.)
|
|
2556
2785
|
const threadKey = slackContinuationToken(ev.channelId, ev.threadTs);
|
|
2557
2786
|
args.waitUntil(
|
|
2558
|
-
|
|
2787
|
+
// A human's voice clip is heard here unless the thread was stood down (OPE-2524): `shouldDeliverReply` drops a bot
|
|
2788
|
+
// author and a stood-down thread, so their clips are never paid for. The clip test comes first, so a message with
|
|
2789
|
+
// no clip pays no extra store read.
|
|
2790
|
+
fifo.enqueue(threadKey, async () => {
|
|
2791
|
+
const heard = !ev.botId && hasSlackVoiceClip(ev) && (await threadHearsClips(p, ev.threadTs!)) ? await hearVoice(ev) : ev;
|
|
2792
|
+
return routeMessageEvent(heard, liveDeps(send)).then(logOutcome("engaged-thread", ev.channelId, ev.threadTs!));
|
|
2793
|
+
}),
|
|
2559
2794
|
);
|
|
2560
2795
|
return OK();
|
|
2561
2796
|
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
// THE SLACK APP HOME TAB (OPE-2451) โ the person's own page: what the agent
|
|
2
|
-
// what their domains know, and their rules (OPE-2456). Core owns the page model
|
|
1
|
+
// THE SLACK APP HOME TAB (OPE-2451) โ the person's own page: what the agent does for them (OPE-2522),
|
|
2
|
+
// what it remembers about them, what their domains know, and their rules (OPE-2456). Core owns the page model
|
|
3
3
|
// (`home/home-view.ts`); this module is the Slack half: it parses the three Slack deliveries the
|
|
4
4
|
// page needs, builds the VERIFIED viewer, renders Block Kit, and drives the dialogs.
|
|
5
5
|
//
|
|
@@ -24,6 +24,7 @@
|
|
|
24
24
|
// A guest, or a person whose only route into a domain is a Slack Connect channel, sees their own
|
|
25
25
|
// memory and no domain's.
|
|
26
26
|
|
|
27
|
+
import { auditErrorDetail } from "@open-everywhere/core/agent/adapter-api.js";
|
|
27
28
|
import {
|
|
28
29
|
appendAudit,
|
|
29
30
|
bindFrom,
|
|
@@ -214,6 +215,10 @@ function renderBlock(b: HomeBlock): SlackBlock[] {
|
|
|
214
215
|
action_id: `${HOME_ACTION_PREFIX}item`,
|
|
215
216
|
options: b.actions.map((a) => ({ text: plain(a.label), value: `${a.id}|${b.ref}` })),
|
|
216
217
|
};
|
|
218
|
+
} else if (b.link && /^https:\/\//u.test(b.link) && b.link.length <= 3000) {
|
|
219
|
+
// A link button (OPE-2522). Slack still sends a click on it; `oe_home:link` names no registered action, so the
|
|
220
|
+
// front route claims it and runs nothing.
|
|
221
|
+
section.accessory = { type: "button", action_id: `${HOME_ACTION_PREFIX}link`, text: plain("Open"), url: b.link };
|
|
217
222
|
}
|
|
218
223
|
return [section];
|
|
219
224
|
}
|
|
@@ -346,6 +351,7 @@ export async function buildSlackHomeViewer(ids: ParsedHomeInteraction, deps: Sla
|
|
|
346
351
|
platform: partition.platform,
|
|
347
352
|
workspace: partition.workspace,
|
|
348
353
|
principalId: binding.principalId,
|
|
354
|
+
...(ids.enterpriseId ? { enterprise: ids.enterpriseId } : {}),
|
|
349
355
|
...(dmId ? { personal: partition } : {}),
|
|
350
356
|
trust,
|
|
351
357
|
domains: homeDomainsFor(trust, memberships),
|
|
@@ -442,7 +448,7 @@ async function publishHome(userId: string, viewer: HomeViewer, deps: SlackHomeDe
|
|
|
442
448
|
trust: viewer.trust,
|
|
443
449
|
domains: viewer.domains.length,
|
|
444
450
|
notice: notice !== undefined,
|
|
445
|
-
...(ok ? {} : { error: typeof res?.error === "string" ? res.error : "no response" }),
|
|
451
|
+
...(ok ? {} : { error: auditErrorDetail(typeof res?.error === "string" ? res.error : "no response").error }),
|
|
446
452
|
},
|
|
447
453
|
}).catch(() => undefined);
|
|
448
454
|
return ok;
|
|
@@ -55,6 +55,13 @@ export interface SlackIdentityInput {
|
|
|
55
55
|
* fails closed (external), so a DM's shared read layer is `public`, never org/dm-shared.
|
|
56
56
|
*/
|
|
57
57
|
readonly dmPartnerTrust?: "internal" | "external";
|
|
58
|
+
/**
|
|
59
|
+
* For a 1:1 DM only: the thread root of the Slack agent session the message belongs to (OPE-2519) โ
|
|
60
|
+
* its `thread_ts`, or its own `ts` for a top-level message, which opens a session. It names the
|
|
61
|
+
* session whose state OE sets (channels/slack-agent-session.ts). It changes no reply target: the
|
|
62
|
+
* reply still reads `thread_ts`.
|
|
63
|
+
*/
|
|
64
|
+
readonly agentSessionTs?: string;
|
|
58
65
|
}
|
|
59
66
|
|
|
60
67
|
/** Build the auth context OpenEverywhere dispatches with โ raw facts as string attributes. */
|
|
@@ -71,6 +78,7 @@ export function buildSlackAuth(input: SlackIdentityInput): OpenEverywhereSlackAu
|
|
|
71
78
|
if (typeof input.isExternal === "boolean") attributes.is_ext_shared = String(input.isExternal);
|
|
72
79
|
if (typeof input.trustChecked === "boolean") attributes.trust_checked = String(input.trustChecked);
|
|
73
80
|
if (input.dmPartnerTrust) attributes.dm_partner_trust = input.dmPartnerTrust;
|
|
81
|
+
if (input.agentSessionTs) attributes.agent_session_ts = input.agentSessionTs;
|
|
74
82
|
// Root-DM grain stamp (window-tool-gating ยงD-DM, widened to group DMs): a 1:1 DM OR an mpim (group
|
|
75
83
|
// DM) is a STANDING seat โ narrowed hands โ both are closed multi-party conversations with no
|
|
76
84
|
// channel identity. A DM is unambiguously channelType "im" (the DM path always sets it); the
|
|
@@ -196,7 +204,7 @@ function enterpriseIdOfUser(u: Record<string, unknown>): string | undefined {
|
|
|
196
204
|
* PURE and SYNCHRONOUS โ the register read is (identity/trust.ts's own contract), so this adds no
|
|
197
205
|
* I/O to a leg that already pays for one `users.info`.
|
|
198
206
|
*/
|
|
199
|
-
|
|
207
|
+
function slackWorkspaceVouched(teamId: string | undefined, enterpriseId: string | undefined): boolean {
|
|
200
208
|
const team = surfaceKeySegment(teamId);
|
|
201
209
|
if (team.length === 0) return false; // no workspace named โ nothing to ask about โ not vouched
|
|
202
210
|
const enterprise = surfaceKeySegment(enterpriseId);
|