@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 +24 -4
- package/dist/interactive/index.d.ts +68 -4
- package/dist/interactive/index.js +432 -0
- package/docs/README.md +1 -0
- package/docs/concepts/interactive-requests.md +85 -0
- package/docs/reference/channel-slack-boundary.md +6 -3
- package/docs/reference/exports.md +1 -1
- package/docs/reference/source-layout.md +1 -0
- package/package.json +1 -1
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
|
|
9
|
-
Socket Mode transport helpers. It does not create or run
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
29
|
-
|
|
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
|
-
-
|
|
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
|
|
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
|