@cuylabs/channel-slack 0.11.0 → 0.12.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/README.md CHANGED
@@ -4,10 +4,10 @@ Agent-runtime-agnostic Slack channel primitives.
4
4
 
5
5
  This package owns reusable Slack mechanics: event parsing, message admission,
6
6
  history loading, formatting, setup inspection, auth helpers, entrypoint
7
- normalization, artifact publishing, interactive request rendering/storage,
8
- response sink contracts, Slack Assistant/classic message mounting, and HTTP or
9
- Socket Mode transport helpers. It does not create or run an agent, and it does
10
- not depend on an agent SDK.
7
+ normalization, artifact publishing, interactive request rendering/storage and
8
+ controller wiring, response sink contracts, Slack Assistant/classic message
9
+ mounting, and HTTP or Socket Mode transport helpers. It does not create or run
10
+ an agent, and it does not depend on an agent SDK.
11
11
 
12
12
  Runtime-specific adapters should compose these primitives with their own turn
13
13
  types, event streams, tools, prompts, and deployment policy.
@@ -71,9 +71,29 @@ await mountSlackAppSocket({
71
71
  });
72
72
  ```
73
73
 
74
+ Add the Slack-native approval/human-input controller from the `interactive`
75
+ subpath when your runtime can pause on interactive request events:
76
+
77
+ ```typescript
78
+ import { createSlackInteractiveController } from "@cuylabs/channel-slack/interactive";
79
+ import { mountSlackAppSocket } from "@cuylabs/channel-slack/socket";
80
+
81
+ const interactive = createSlackInteractiveController({
82
+ namespace: "my_agent_slack",
83
+ });
84
+
85
+ await mountSlackAppSocket({
86
+ source,
87
+ interactive,
88
+ appToken: process.env.SLACK_APP_TOKEN,
89
+ botToken: process.env.SLACK_BOT_TOKEN,
90
+ });
91
+ ```
92
+
74
93
  ## Documentation
75
94
 
76
95
  - [Package boundary](docs/reference/channel-slack-boundary.md)
96
+ - [Interactive requests](docs/concepts/interactive-requests.md)
77
97
  - [Exports and peer expectations](docs/reference/exports.md)
78
98
  - [Source layout](docs/reference/source-layout.md)
79
99
  - [Docs index](docs/README.md)
@@ -1,7 +1,9 @@
1
- import { c as SlackInteractiveApprovalRequest, S as SlackInteractiveActionIds, e as SlackInteractiveHumanInputRequest, l as SlackInteractiveResolution, i as SlackInteractiveRequestRecord, k as SlackInteractiveRequestStore } from '../types-Cywfj8Mj.js';
2
- export { a as SlackInteractiveActor, b as SlackInteractiveApprovalAction, d as SlackInteractiveHumanInputOption, f as SlackInteractiveHumanInputResponse, g as SlackInteractiveMessageTarget, h as SlackInteractiveRequestHandler, j as SlackInteractiveRequestStatus, m as SlackInteractiveStoredRequest } from '../types-Cywfj8Mj.js';
1
+ import { c as SlackInteractiveApprovalRequest, S as SlackInteractiveActionIds, e as SlackInteractiveHumanInputRequest, l as SlackInteractiveResolution, i as SlackInteractiveRequestRecord, k as SlackInteractiveRequestStore, f as SlackInteractiveHumanInputResponse, a as SlackInteractiveActor } from '../types-Cywfj8Mj.js';
2
+ export { b as SlackInteractiveApprovalAction, d as SlackInteractiveHumanInputOption, g as SlackInteractiveMessageTarget, h as SlackInteractiveRequestHandler, j as SlackInteractiveRequestStatus, m as SlackInteractiveStoredRequest } from '../types-Cywfj8Mj.js';
3
3
  import { View } from '@slack/types';
4
- export { c as SlackInteractiveMessage, d as SlackInteractiveMessageRef, f as SlackInteractiveRequestBaseContext, g as SlackInteractiveRequestContext, i as SlackInteractiveRequestKind, j as SlackInteractiveResponder } from '../interactive-CbKYkkc_.js';
4
+ import { App } from '@slack/bolt';
5
+ import { g as SlackInteractiveRequestContext } from '../interactive-CbKYkkc_.js';
6
+ export { c as SlackInteractiveMessage, d as SlackInteractiveMessageRef, f as SlackInteractiveRequestBaseContext, i as SlackInteractiveRequestKind, j as SlackInteractiveResponder } from '../interactive-CbKYkkc_.js';
5
7
  import '../activity-ByrD9Ftr.js';
6
8
 
7
9
  declare function buildApprovalRequestMessage(request: SlackInteractiveApprovalRequest, actionIds: SlackInteractiveActionIds): {
@@ -64,4 +66,66 @@ declare function prunePostgresSlackInteractiveRequestStore({ client, pruneBatchS
64
66
  tableName?: string;
65
67
  }): Promise<PostgresSlackInteractiveRequestPruneResult>;
66
68
 
67
- export { type PostgresSlackInteractiveRequestPruneResult, type PostgresSlackInteractiveRequestStore, type PostgresSlackInteractiveRequestStoreOptions, SlackInteractiveActionIds, SlackInteractiveApprovalRequest, SlackInteractiveHumanInputRequest, type SlackInteractivePostgresClient, SlackInteractiveRequestRecord, SlackInteractiveRequestStore, SlackInteractiveResolution, buildApprovalRequestMessage, buildHumanInputModal, buildHumanInputRequestMessage, buildResolvedMessage, cloneRecord, createInMemorySlackInteractiveRequestStore, createPostgresSlackInteractiveRequestStore, decodeActionValue, encodeActionValue, initializePostgresSlackInteractiveRequestStore, nowIso, prunePostgresSlackInteractiveRequestStore };
69
+ type SlackApprovalResolution = Extract<SlackInteractiveResolution, {
70
+ kind: "approval";
71
+ }>;
72
+ interface SlackInteractiveRequestWaitOptions {
73
+ /**
74
+ * Abort waiting for this request. The controller removes its local waiter and
75
+ * deletes the pending store record when the signal fires.
76
+ */
77
+ signal?: AbortSignal;
78
+ /**
79
+ * Override the controller-level request timeout for this request. Use `0` to
80
+ * disable timeout cleanup for this request.
81
+ */
82
+ timeoutMs?: number;
83
+ }
84
+ interface SlackInteractiveControllerOptions {
85
+ store?: SlackInteractiveRequestStore;
86
+ /**
87
+ * Stable namespace for default Slack action IDs. Use this when installing
88
+ * multiple interactive controllers in the same Slack app.
89
+ *
90
+ * @default "agent_slack"
91
+ */
92
+ namespace?: string;
93
+ actionIds?: Partial<SlackInteractiveActionIds>;
94
+ /**
95
+ * Default timeout for local waiters and pending store records.
96
+ *
97
+ * @default 300000
98
+ */
99
+ requestTimeoutMs?: number;
100
+ /**
101
+ * Called on every successful Slack resolution after the local waiter, if
102
+ * present, is resolved. Use this to fan out decisions to another runtime.
103
+ */
104
+ onResolve?: (requestId: string, resolution: SlackInteractiveResolution) => void | Promise<void>;
105
+ /**
106
+ * Authorization hook for approving/responding to pending requests.
107
+ *
108
+ * Defaults to the original Slack requester only. Return `true` for delegated
109
+ * approvers, channel owners, or admin policy checks.
110
+ */
111
+ authorize?: (record: SlackInteractiveRequestRecord, actor: SlackInteractiveActor) => boolean | Promise<boolean>;
112
+ }
113
+ interface SlackInteractiveController {
114
+ readonly actionIds: SlackInteractiveActionIds;
115
+ readonly store: SlackInteractiveRequestStore;
116
+ approval: {
117
+ onRequest(request: SlackInteractiveApprovalRequest, options?: SlackInteractiveRequestWaitOptions): Promise<SlackApprovalResolution>;
118
+ };
119
+ humanInput: {
120
+ onRequest(request: SlackInteractiveHumanInputRequest, options?: SlackInteractiveRequestWaitOptions): Promise<SlackInteractiveHumanInputResponse>;
121
+ };
122
+ /** Reject one pending in-process waiter and delete its pending store record. */
123
+ cancel(requestId: string, reason?: string): Promise<boolean>;
124
+ /** Shutdown helper: cancel every pending request created by this controller. */
125
+ cancelAll(reason?: string): Promise<void>;
126
+ handleInteractiveRequest(context: SlackInteractiveRequestContext): Promise<boolean>;
127
+ install(app: App): void;
128
+ }
129
+ declare function createSlackInteractiveController(options?: SlackInteractiveControllerOptions): SlackInteractiveController;
130
+
131
+ export { type PostgresSlackInteractiveRequestPruneResult, type PostgresSlackInteractiveRequestStore, type PostgresSlackInteractiveRequestStoreOptions, SlackInteractiveActionIds, SlackInteractiveActor, SlackInteractiveApprovalRequest, type SlackInteractiveController, type SlackInteractiveControllerOptions, SlackInteractiveHumanInputRequest, SlackInteractiveHumanInputResponse, type SlackInteractivePostgresClient, SlackInteractiveRequestContext, SlackInteractiveRequestRecord, SlackInteractiveRequestStore, type SlackInteractiveRequestWaitOptions, SlackInteractiveResolution, buildApprovalRequestMessage, buildHumanInputModal, buildHumanInputRequestMessage, buildResolvedMessage, cloneRecord, createInMemorySlackInteractiveRequestStore, createPostgresSlackInteractiveRequestStore, createSlackInteractiveController, decodeActionValue, encodeActionValue, initializePostgresSlackInteractiveRequestStore, nowIso, prunePostgresSlackInteractiveRequestStore };
@@ -1,3 +1,7 @@
1
+ import {
2
+ openSlackModal
3
+ } from "../chunk-IRFKUPJN.js";
4
+
1
5
  // src/interactive/blocks.ts
2
6
  var MAX_BLOCK_TEXT = 2800;
3
7
  function buildApprovalRequestMessage(request, actionIds) {
@@ -634,6 +638,433 @@ async function importPostgresPoolConstructor() {
634
638
  function formatImportError(error) {
635
639
  return error instanceof Error ? error.message : String(error);
636
640
  }
641
+
642
+ // src/interactive/controller.ts
643
+ var DEFAULT_NAMESPACE = "agent_slack";
644
+ var DEFAULT_REQUEST_TIMEOUT_MS = 5 * 60 * 1e3;
645
+ var installedActionIds = /* @__PURE__ */ new WeakMap();
646
+ function createSlackInteractiveController(options = {}) {
647
+ const store = options.store ?? createInMemorySlackInteractiveRequestStore();
648
+ const actionIds = resolveActionIds(
649
+ options.namespace ?? DEFAULT_NAMESPACE,
650
+ options.actionIds
651
+ );
652
+ const requestTimeoutMs = options.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS;
653
+ const waiters = /* @__PURE__ */ new Map();
654
+ const pendingIds = /* @__PURE__ */ new Set();
655
+ async function ensurePending(kind, request) {
656
+ const existing = await store.get(request.id);
657
+ if (existing) {
658
+ assertRecordKind(existing, kind);
659
+ trackPending(existing);
660
+ return existing;
661
+ }
662
+ const createdAt = nowIso();
663
+ const record = await store.upsert({
664
+ id: request.id,
665
+ kind,
666
+ request,
667
+ status: "pending",
668
+ createdAt,
669
+ updatedAt: createdAt
670
+ });
671
+ assertRecordKind(record, kind);
672
+ trackPending(record);
673
+ return record;
674
+ }
675
+ function trackPending(record) {
676
+ if (record.status === "pending") {
677
+ pendingIds.add(record.id);
678
+ } else {
679
+ pendingIds.delete(record.id);
680
+ }
681
+ }
682
+ async function waitForResolution(kind, request, waitOptions = {}) {
683
+ const existing = await ensurePending(kind, request);
684
+ if (existing.status === "resolved") {
685
+ if (!existing.resolution) {
686
+ throw new Error(
687
+ `Slack interactive request ${request.id} is resolved without a resolution.`
688
+ );
689
+ }
690
+ return existing.resolution;
691
+ }
692
+ if (waiters.has(request.id)) {
693
+ throw new Error(
694
+ `Slack interactive request is already waiting: ${request.id}. Resolve or cancel the in-flight request before requesting again.`
695
+ );
696
+ }
697
+ return await new Promise((resolve, reject) => {
698
+ const cleanupCallbacks = [];
699
+ const timeoutMs = waitOptions.timeoutMs ?? requestTimeoutMs;
700
+ if (timeoutMs > 0) {
701
+ const timeoutId = setTimeout(() => {
702
+ void cancel(request.id, "Slack interactive request timed out.");
703
+ }, timeoutMs);
704
+ cleanupCallbacks.push(() => clearTimeout(timeoutId));
705
+ }
706
+ let abortImmediately = false;
707
+ if (waitOptions.signal) {
708
+ const onAbort = () => {
709
+ void cancel(request.id, "Slack interactive request aborted.");
710
+ };
711
+ if (waitOptions.signal.aborted) {
712
+ abortImmediately = true;
713
+ } else {
714
+ waitOptions.signal.addEventListener("abort", onAbort, {
715
+ once: true
716
+ });
717
+ cleanupCallbacks.push(
718
+ () => waitOptions.signal?.removeEventListener("abort", onAbort)
719
+ );
720
+ }
721
+ }
722
+ waiters.set(request.id, {
723
+ resolve,
724
+ reject,
725
+ cleanup: () => {
726
+ for (const cleanup of cleanupCallbacks.splice(0)) {
727
+ cleanup();
728
+ }
729
+ }
730
+ });
731
+ if (abortImmediately) {
732
+ void cancel(request.id, "Slack interactive request aborted.");
733
+ }
734
+ });
735
+ }
736
+ async function resolveRequest(requestId, resolution) {
737
+ const record = await store.resolve(requestId, resolution);
738
+ if (!record) {
739
+ const waiter2 = waiters.get(requestId);
740
+ if (waiter2) {
741
+ waiters.delete(requestId);
742
+ waiter2.cleanup();
743
+ waiter2.reject(
744
+ new Error(`Slack interactive request no longer exists: ${requestId}`)
745
+ );
746
+ }
747
+ pendingIds.delete(requestId);
748
+ return void 0;
749
+ }
750
+ const resolvedResolution = record.resolution ?? resolution;
751
+ const waiter = waiters.get(requestId);
752
+ if (waiter) {
753
+ waiters.delete(requestId);
754
+ waiter.cleanup();
755
+ waiter.resolve(resolvedResolution);
756
+ }
757
+ pendingIds.delete(requestId);
758
+ await options.onResolve?.(requestId, resolvedResolution);
759
+ return { record, resolution: resolvedResolution };
760
+ }
761
+ async function cancel(requestId, reason = "Cancelled") {
762
+ const waiter = waiters.get(requestId);
763
+ if (waiter) {
764
+ waiters.delete(requestId);
765
+ waiter.cleanup();
766
+ waiter.reject(new Error(reason));
767
+ }
768
+ const existing = await store.get(requestId);
769
+ if (existing?.status === "pending") {
770
+ await store.delete(requestId);
771
+ pendingIds.delete(requestId);
772
+ return true;
773
+ }
774
+ pendingIds.delete(requestId);
775
+ return Boolean(waiter);
776
+ }
777
+ async function cancelAll(reason = "Cancelled") {
778
+ await Promise.all(
779
+ [...pendingIds].map((requestId) => cancel(requestId, reason))
780
+ );
781
+ }
782
+ async function handleInteractiveRequest(context) {
783
+ const request = context.request;
784
+ const record = await ensurePending(context.kind, request);
785
+ if (record.status === "resolved" || record.target) {
786
+ return true;
787
+ }
788
+ const message = context.kind === "approval" ? buildApprovalRequestMessage(
789
+ request,
790
+ actionIds
791
+ ) : buildHumanInputRequestMessage(
792
+ request,
793
+ actionIds
794
+ );
795
+ const ref = await context.responder.postMessage(message);
796
+ await store.attachTarget(request.id, {
797
+ channel: ref.channel,
798
+ ts: ref.ts,
799
+ userId: context.user.userId,
800
+ teamId: context.user.teamId,
801
+ ...context.slackActivity.threadTs ? { threadTs: context.slackActivity.threadTs } : {}
802
+ });
803
+ return true;
804
+ }
805
+ function install(app) {
806
+ assertActionIdsCanInstall(app, actionIds);
807
+ app.action(actionIds.approvalAllow, async (args) => {
808
+ await handleAction(args, {
809
+ kind: "approval",
810
+ action: "allow"
811
+ });
812
+ });
813
+ app.action(actionIds.approvalDeny, async (args) => {
814
+ await handleAction(args, {
815
+ kind: "approval",
816
+ action: "deny"
817
+ });
818
+ });
819
+ app.action(actionIds.approvalRemember, async (args) => {
820
+ const value = firstActionValue(args);
821
+ const rememberScope = typeof value.rememberScope === "string" ? value.rememberScope : void 0;
822
+ await handleAction(args, {
823
+ kind: "approval",
824
+ action: "remember",
825
+ ...rememberScope ? { rememberScope } : {}
826
+ });
827
+ });
828
+ app.action(actionIds.humanConfirm, async (args) => {
829
+ await handleAction(args, {
830
+ kind: "human-input",
831
+ response: { kind: "confirm", confirmed: true, text: "Confirmed" }
832
+ });
833
+ });
834
+ app.action(actionIds.humanDeny, async (args) => {
835
+ await handleAction(args, {
836
+ kind: "human-input",
837
+ response: { kind: "confirm", confirmed: false, text: "Cancelled" }
838
+ });
839
+ });
840
+ app.action(actionIds.humanOpen, async (args) => {
841
+ await openHumanInputModal(args);
842
+ });
843
+ app.view(actionIds.humanSubmit, async (args) => {
844
+ await submitHumanInputModal(args);
845
+ });
846
+ }
847
+ async function handleAction(args, resolutionInput) {
848
+ const actionArgs = args;
849
+ await actionArgs.ack();
850
+ const requestId = extractRequestId(firstActionValue(args));
851
+ if (!requestId) return;
852
+ const record = await store.get(requestId);
853
+ if (!record || record.status === "resolved") {
854
+ return;
855
+ }
856
+ const actor = extractActor(actionArgs.body);
857
+ if (!await isAuthorized(record, actor)) {
858
+ await postEphemeral(
859
+ actionArgs,
860
+ "Only the original requester can resolve this request."
861
+ );
862
+ return;
863
+ }
864
+ const resolved = await resolveRequest(requestId, resolutionInput);
865
+ if (resolved?.record.target) {
866
+ await updateOriginalMessage(
867
+ actionArgs,
868
+ resolved.record,
869
+ resolved.resolution
870
+ );
871
+ }
872
+ }
873
+ async function openHumanInputModal(args) {
874
+ const actionArgs = args;
875
+ await actionArgs.ack();
876
+ const requestId = extractRequestId(firstActionValue(args));
877
+ if (!requestId || !actionArgs.body.trigger_id) return;
878
+ const record = await store.get(requestId);
879
+ if (!record || record.kind !== "human-input") return;
880
+ const actor = extractActor(actionArgs.body);
881
+ if (!await isAuthorized(record, actor)) {
882
+ await postEphemeral(
883
+ actionArgs,
884
+ "Only the original requester can answer this request."
885
+ );
886
+ return;
887
+ }
888
+ await openSlackModal({
889
+ client: actionArgs.client,
890
+ triggerId: actionArgs.body.trigger_id,
891
+ view: buildHumanInputModal(
892
+ record.request,
893
+ actionIds
894
+ )
895
+ });
896
+ }
897
+ async function submitHumanInputModal(args) {
898
+ const viewArgs = args;
899
+ await viewArgs.ack();
900
+ const requestId = extractRequestIdFromView(viewArgs.view);
901
+ if (!requestId) return;
902
+ const record = await store.get(requestId);
903
+ if (!record || record.kind !== "human-input" || record.status === "resolved") {
904
+ return;
905
+ }
906
+ const actor = extractActor(viewArgs.body);
907
+ if (!await isAuthorized(record, actor)) {
908
+ return;
909
+ }
910
+ const response = responseFromView(
911
+ record.request,
912
+ viewArgs.view
913
+ );
914
+ const resolution = {
915
+ kind: "human-input",
916
+ response
917
+ };
918
+ const resolved = await resolveRequest(requestId, resolution);
919
+ if (resolved?.record.target) {
920
+ await viewArgs.client.chat.update({
921
+ channel: resolved.record.target.channel,
922
+ ts: resolved.record.target.ts,
923
+ ...buildResolvedMessage(
924
+ "Slack response received.",
925
+ resolved.resolution
926
+ )
927
+ });
928
+ }
929
+ }
930
+ async function isAuthorized(record, actor) {
931
+ if (options.authorize) {
932
+ return await options.authorize(record, actor);
933
+ }
934
+ return record.target?.userId === actor.userId;
935
+ }
936
+ async function updateOriginalMessage(args, record, resolution) {
937
+ const target = record.target;
938
+ if (!target) return;
939
+ const label = resolution.kind === "approval" ? `${resolution.action} selected.` : resolution.response.text;
940
+ await args.client.chat.update({
941
+ channel: target.channel,
942
+ ts: target.ts,
943
+ ...buildResolvedMessage(label, resolution)
944
+ });
945
+ }
946
+ return {
947
+ actionIds,
948
+ store,
949
+ approval: {
950
+ async onRequest(request, options2) {
951
+ const resolution = await waitForResolution(
952
+ "approval",
953
+ request,
954
+ options2
955
+ );
956
+ if (resolution.kind !== "approval") {
957
+ throw new Error(
958
+ `Unexpected human-input resolution for ${request.id}.`
959
+ );
960
+ }
961
+ return resolution;
962
+ }
963
+ },
964
+ humanInput: {
965
+ async onRequest(request, options2) {
966
+ const resolution = await waitForResolution(
967
+ "human-input",
968
+ request,
969
+ options2
970
+ );
971
+ if (resolution.kind !== "human-input") {
972
+ throw new Error(`Unexpected approval resolution for ${request.id}.`);
973
+ }
974
+ return resolution.response;
975
+ }
976
+ },
977
+ cancel,
978
+ cancelAll,
979
+ handleInteractiveRequest,
980
+ install
981
+ };
982
+ }
983
+ function assertRecordKind(record, kind) {
984
+ if (record.kind !== kind) {
985
+ throw new Error(
986
+ `Slack interactive request ${record.id} already exists as ${record.kind}; cannot reuse it as ${kind}.`
987
+ );
988
+ }
989
+ }
990
+ function resolveActionIds(namespace, overrides) {
991
+ const prefix = normalizeActionIdNamespace(namespace);
992
+ return {
993
+ approvalAllow: `${prefix}_approval_allow`,
994
+ approvalDeny: `${prefix}_approval_deny`,
995
+ approvalRemember: `${prefix}_approval_remember`,
996
+ humanConfirm: `${prefix}_human_confirm`,
997
+ humanDeny: `${prefix}_human_deny`,
998
+ humanOpen: `${prefix}_human_open`,
999
+ humanSubmit: `${prefix}_human_submit`,
1000
+ ...overrides ?? {}
1001
+ };
1002
+ }
1003
+ function normalizeActionIdNamespace(namespace) {
1004
+ const trimmed = namespace.trim();
1005
+ if (!trimmed) {
1006
+ throw new Error("Slack interactive action namespace cannot be empty.");
1007
+ }
1008
+ return trimmed;
1009
+ }
1010
+ function assertActionIdsCanInstall(app, actionIds) {
1011
+ const ids = Object.values(actionIds);
1012
+ const duplicateWithinController = ids.find(
1013
+ (id, index) => ids.indexOf(id) !== index
1014
+ );
1015
+ if (duplicateWithinController) {
1016
+ throw new Error(
1017
+ `Duplicate Slack interactive action id configured: ${duplicateWithinController}`
1018
+ );
1019
+ }
1020
+ const appKey = app;
1021
+ const installed = installedActionIds.get(appKey) ?? /* @__PURE__ */ new Set();
1022
+ const duplicate = ids.find((id) => installed.has(id));
1023
+ if (duplicate) {
1024
+ throw new Error(
1025
+ `Slack interactive action id '${duplicate}' is already installed on this Bolt app. Provide a unique createSlackInteractiveController({ namespace }) or actionIds config.`
1026
+ );
1027
+ }
1028
+ for (const id of ids) {
1029
+ installed.add(id);
1030
+ }
1031
+ installedActionIds.set(appKey, installed);
1032
+ }
1033
+ function firstActionValue(args) {
1034
+ const body = args.body;
1035
+ return decodeActionValue(body.actions?.[0]?.value);
1036
+ }
1037
+ function extractRequestId(value) {
1038
+ return typeof value.requestId === "string" && value.requestId.length > 0 ? value.requestId : void 0;
1039
+ }
1040
+ function extractRequestIdFromView(view) {
1041
+ return extractRequestId(decodeActionValue(view.private_metadata));
1042
+ }
1043
+ function extractActor(body) {
1044
+ return {
1045
+ userId: body.user?.id ?? "unknown",
1046
+ ...body.user?.team_id ?? body.team?.id ? { teamId: body.user?.team_id ?? body.team?.id } : {}
1047
+ };
1048
+ }
1049
+ async function postEphemeral(args, text) {
1050
+ const channel = args.body.channel?.id;
1051
+ const user = args.body.user?.id;
1052
+ if (!channel || !user || !args.client.chat.postEphemeral) return;
1053
+ await args.client.chat.postEphemeral({ channel, user, text });
1054
+ }
1055
+ function responseFromView(request, view) {
1056
+ const input = view.state?.values?.input?.value;
1057
+ if (request.kind === "choice") {
1058
+ const selected = input?.selected_options?.map((option) => option.value ?? "").filter(Boolean) ?? (input?.selected_option?.value ? [input.selected_option.value] : []);
1059
+ return {
1060
+ kind: "choice",
1061
+ selected,
1062
+ text: selected.join(", ")
1063
+ };
1064
+ }
1065
+ const text = input?.value ?? "";
1066
+ return { kind: "text", text };
1067
+ }
637
1068
  export {
638
1069
  buildApprovalRequestMessage,
639
1070
  buildHumanInputModal,
@@ -642,6 +1073,7 @@ export {
642
1073
  cloneRecord,
643
1074
  createInMemorySlackInteractiveRequestStore,
644
1075
  createPostgresSlackInteractiveRequestStore,
1076
+ createSlackInteractiveController,
645
1077
  decodeActionValue,
646
1078
  encodeActionValue,
647
1079
  initializePostgresSlackInteractiveRequestStore,
package/docs/README.md CHANGED
@@ -15,6 +15,7 @@ your adapter or application needs.
15
15
  - [Activity parsing](concepts/activity.md)
16
16
  - [Artifacts](concepts/artifacts.md)
17
17
  - [Entrypoints](concepts/entrypoints.md)
18
+ - [Interactive requests](concepts/interactive-requests.md)
18
19
  - [Message policy](concepts/message-policy.md)
19
20
  - [Setup requirements](concepts/setup-requirements.md)
20
21
  - [Supplemental history](concepts/supplemental-history.md)
@@ -0,0 +1,85 @@
1
+ # Interactive Requests
2
+
3
+ Slack interactive requests render approval and human-input requests as Slack
4
+ buttons and modals. The controller is runtime-neutral; applications adapt their
5
+ runtime's request shapes into the generic Slack request types exported from
6
+ `@cuylabs/channel-slack/interactive`.
7
+
8
+ Use this import path for both generic Slack runtimes and runtime adapters such
9
+ as `@cuylabs/channel-slack-agent-core`:
10
+
11
+ ```typescript
12
+ import {
13
+ createPostgresSlackInteractiveRequestStore,
14
+ createSlackInteractiveController,
15
+ } from "@cuylabs/channel-slack/interactive";
16
+ import { mountSlackAppSocket } from "@cuylabs/channel-slack/socket";
17
+
18
+ const interactiveStore = createPostgresSlackInteractiveRequestStore({
19
+ connectionString: process.env.DATABASE_URL,
20
+ schema: "agent",
21
+ });
22
+
23
+ const interactive = createSlackInteractiveController({
24
+ store: interactiveStore,
25
+ namespace: "my_agent_slack",
26
+ requestTimeoutMs: 5 * 60 * 1000,
27
+ });
28
+
29
+ await mountSlackAppSocket({
30
+ source,
31
+ interactive,
32
+ appToken: process.env.SLACK_APP_TOKEN,
33
+ botToken: process.env.SLACK_BOT_TOKEN,
34
+ });
35
+ ```
36
+
37
+ `mountSlackApp`, `mountSlackAppSocket`, and `installSlackAppSurface` install the
38
+ controller on the Bolt app and route approval/human-input events to
39
+ `handleInteractiveRequest`. If you manually compose lower-level Bolt helpers,
40
+ call `interactive.install(boltApp)` and pass
41
+ `interactive.handleInteractiveRequest` to the Slack runtime or adapter options.
42
+
43
+ ## Runtime Hooks
44
+
45
+ The controller exposes request hooks for runtimes that own approval and
46
+ human-input decisions:
47
+
48
+ ```typescript
49
+ const approval = interactive.approval.onRequest;
50
+ const humanInput = interactive.humanInput.onRequest;
51
+ ```
52
+
53
+ Use those hooks at the runtime boundary where your agent pauses for an approval
54
+ or human response. The Slack package deliberately keeps the request shapes
55
+ generic; runtime-specific adapters should map their event/request types before
56
+ calling the controller.
57
+
58
+ Request IDs must be unique across pending approval and human-input requests.
59
+ The controller rejects reuse of the same ID for a different request kind so one
60
+ Slack button cannot resolve the wrong runtime wait.
61
+
62
+ ## Resolution Behavior
63
+
64
+ The controller persists pending requests before rendering Slack buttons. Once a
65
+ request is resolved, duplicate button clicks return the stored first-wins
66
+ resolution and do not overwrite it. Resolved records are not rendered again if
67
+ the same event is replayed.
68
+
69
+ By default, only the Slack user who triggered the original turn can resolve the
70
+ request. Pass `authorize(record, actor)` to allow delegated approvers, channel
71
+ owners, or workspace-admin policy.
72
+
73
+ ## Store Choices
74
+
75
+ - `createInMemorySlackInteractiveRequestStore` is useful for tests and local
76
+ single-process apps.
77
+ - `createPostgresSlackInteractiveRequestStore` persists pending requests across
78
+ restarts and lets multiple Slack workers resolve the same request safely.
79
+
80
+ The Postgres store uses one table keyed by request ID. `resolve(...)` is
81
+ idempotent: the first resolution wins, and later duplicate button clicks return
82
+ the original resolution without overwriting it.
83
+
84
+ Call `close()` during shutdown when the store owns its `pg` pool. Call
85
+ `prune()` manually when you disable the background prune timer.
@@ -25,8 +25,8 @@ contracts.
25
25
  - Target parsing and resolution.
26
26
  - Feedback blocks and action handler.
27
27
  - Slack response sink and chat stream contracts for runtime adapters.
28
- - Slack interactive request Block Kit/modal builders and in-memory/Postgres
29
- stores.
28
+ - Slack interactive request Block Kit/modal builders, in-memory/Postgres stores,
29
+ and controller.
30
30
  - Slack message policy resolver and in-memory/Postgres state stores.
31
31
  - Supplemental history reader, context loader, and visibility policy.
32
32
  - Assistant message parser and thread-context store.
@@ -37,7 +37,10 @@ contracts.
37
37
  - Agent-runtime scopes and context-fragment middleware.
38
38
  - Runtime-specific mapping from Slack entrypoints into an agent turn.
39
39
  - Agent event stream rendering and runtime-specific response orchestration.
40
- - Agent-specific approval and human-input controllers.
40
+ - Runtime-specific mapping from approval and human-input requests into the
41
+ generic Slack interactive request shapes.
42
+ - Runtime-specific wiring of `approval.onRequest` and `humanInput.onRequest`
43
+ into an agent runtime.
41
44
  - Product prompts, tools, audit policy, and deployment policy.
42
45
 
43
46
  Those pieces belong in runtime-specific adapters or product applications.
@@ -12,7 +12,7 @@ keep application code close to the package boundary it uses.
12
12
  | `@cuylabs/channel-slack/core` | no Slack SDK runtime imports | Transport-neutral parsing, formatting, types, sessions, turn context |
13
13
  | `@cuylabs/channel-slack/policy` | `pg` only when using connection-string Postgres state | Message admission and in-memory/Postgres policy state |
14
14
  | `@cuylabs/channel-slack/history` | `@slack/web-api` types | History reader accepts a Slack WebClient or minimal conversations client |
15
- | `@cuylabs/channel-slack/interactive` | `@slack/types`; `pg` only when using connection-string Postgres storage | Slack interactive Block Kit/modal builders and request stores |
15
+ | `@cuylabs/channel-slack/interactive` | `@slack/bolt`, `@slack/types`; `pg` only when using connection-string storage | Slack interactive controller, Block Kit/modal builders, and stores |
16
16
  | `@cuylabs/channel-slack/app-home` | `@slack/bolt`, `@slack/types` | Slack App Home registration helper |
17
17
  | `@cuylabs/channel-slack/artifacts` | no Slack SDK runtime imports; requires a Web API-like client at call time | Text, file, image, link, Canvas, and final-response artifact publishing |
18
18
  | `@cuylabs/channel-slack/auth` | no Slack SDK runtime imports; `pg` is not required | Auth option types, auth resolution, OAuth installation stores |
@@ -21,6 +21,7 @@ src/
21
21
  reader.ts Slack history API reader and prompt formatter
22
22
  visibility-policy.ts model-visible history filters
23
23
  inclusion-policy.ts direct-message supplemental-history inclusion policy
24
+ interactive/ approval/human-input controller, blocks, stores
24
25
  policy/
25
26
  message/ message admission and state-store component
26
27
  setup/ scopes, events, manifests, setup inspection
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cuylabs/channel-slack",
3
- "version": "0.11.0",
3
+ "version": "0.12.0",
4
4
  "description": "Agent-runtime-agnostic Slack channel primitives for AI agents",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",