From f3ee58e5d7083e03f497051a1496cef5ac7b7a22 Mon Sep 17 00:00:00 2001 From: William Bergamin Date: Mon, 30 Mar 2026 17:27:26 -0400 Subject: [PATCH] feat: surface the set_status argument to listeners if required event details are available --- src/App.ts | 14 +++- src/Assistant.ts | 30 ++------- src/context/create-set-status.ts | 22 +++++++ src/context/index.ts | 2 + src/index.ts | 1 + src/types/events/index.ts | 3 +- test/types/set-status.test-d.ts | 71 +++++++++++++++++++++ test/unit/App/middlewares/arguments.spec.ts | 61 ++++++++++++++++++ test/unit/context/create-set-status.spec.ts | 56 ++++++++++++++++ test/unit/helpers/app.ts | 14 ++++ test/unit/helpers/events.ts | 4 ++ 11 files changed, 251 insertions(+), 27 deletions(-) create mode 100644 src/context/create-set-status.ts create mode 100644 test/types/set-status.test-d.ts create mode 100644 test/unit/context/create-set-status.spec.ts diff --git a/src/App.ts b/src/App.ts index 26c58d23c..f0e4de9f3 100644 --- a/src/App.ts +++ b/src/App.ts @@ -12,8 +12,15 @@ import { type SlackCustomFunctionMiddlewareArgs, } from './CustomFunction'; import type { WorkflowStep } from './WorkflowStep'; -import { createFunctionComplete, createFunctionFail, createRespond, createSay, createSayStream } from './context'; -import type { SayStreamFn } from './context'; +import { + createFunctionComplete, + createFunctionFail, + createRespond, + createSay, + createSayStream, + createSetStatus, +} from './context'; +import type { SayStreamFn, SetStatusFn } from './context'; import { type ConversationStore, MemoryStore, conversationContext } from './conversation-store'; import { AppInitializationError, @@ -1054,6 +1061,8 @@ export default class App say?: SayFn; /** SayStream function might be set below */ sayStream?: SayStreamFn; + /** SetStatus function might be set below */ + setStatus?: SetStatusFn; /** Respond function might be set below */ respond?: RespondFn; /** Ack function might be set below */ @@ -1122,6 +1131,7 @@ export default class App const resolvedThreadTs = threadTs ?? eventTs; if (resolvedThreadTs !== undefined) { listenerArgs.sayStream = createSayStream(client, context, eventChannelId, resolvedThreadTs); + listenerArgs.setStatus = createSetStatus(client, eventChannelId, resolvedThreadTs); } } } else if (type === IncomingEventType.Action) { diff --git a/src/Assistant.ts b/src/Assistant.ts index 00a526ccf..4a612ecbc 100644 --- a/src/Assistant.ts +++ b/src/Assistant.ts @@ -1,6 +1,4 @@ import type { - AssistantThreadsSetStatusArguments, - AssistantThreadsSetStatusResponse, AssistantThreadsSetSuggestedPromptsResponse, AssistantThreadsSetTitleResponse, ChatPostMessageArguments, @@ -11,8 +9,9 @@ import { type AssistantThreadContextStore, DefaultThreadContextStore, } from './AssistantThreadContextStore'; -import { createSayStream } from './context'; +import { createSayStream, createSetStatus } from './context'; import type { SayStreamFn } from './context'; +import type { SetStatusFn } from './context'; import { AssistantInitializationError, AssistantMissingPropertyError } from './errors'; import { extractEventChannelId, extractEventThreadTs, isRecord } from './helpers'; import processMiddleware from './middleware/process'; @@ -43,9 +42,6 @@ interface AssistantUtilityArgs { type GetThreadContextUtilFn = () => Promise; type SaveThreadContextUtilFn = () => Promise; -type SetStatusFn = ( - status: string | Omit, -) => Promise; type SetSuggestedPromptsFn = ( params: SetSuggestedPromptsArguments, @@ -186,7 +182,7 @@ export function enrichAssistantArgs( preparedArgs.say = createSay(preparedArgs); preparedArgs.sayStream = createAssistantSayStream(preparedArgs); - preparedArgs.setStatus = createSetStatus(preparedArgs); + preparedArgs.setStatus = createAssistantSetStatus(preparedArgs); preparedArgs.setSuggestedPrompts = createSetSuggestedPrompts(preparedArgs); preparedArgs.setTitle = createSetTitle(preparedArgs); return preparedArgs; @@ -350,24 +346,10 @@ function createAssistantSayStream(args: AllAssistantMiddlewareArgs): SayStreamFn * Creates utility `setStatus()` to set the status and indicate active processing. * https://api.slack.com/methods/assistant.threads.setStatus */ -function createSetStatus(args: AllAssistantMiddlewareArgs): SetStatusFn { +function createAssistantSetStatus(args: AllAssistantMiddlewareArgs): SetStatusFn { const { client, payload } = args; - const { channelId: channel_id, threadTs: thread_ts } = extractThreadInfo(payload); - - return (status: Parameters[0]): Promise => { - if (typeof status === 'string') { - return client.assistant.threads.setStatus({ - channel_id, - thread_ts, - status, - }); - } - return client.assistant.threads.setStatus({ - channel_id, - thread_ts, - ...status, - }); - }; + const { channelId, threadTs } = extractThreadInfo(payload); + return createSetStatus(client, channelId, threadTs); } /** diff --git a/src/context/create-set-status.ts b/src/context/create-set-status.ts new file mode 100644 index 000000000..7e2a98a62 --- /dev/null +++ b/src/context/create-set-status.ts @@ -0,0 +1,22 @@ +import type { AssistantThreadsSetStatusArguments, AssistantThreadsSetStatusResponse, WebClient } from '@slack/web-api'; + +export type SetStatusArguments = Omit; + +export type SetStatusFn = (status: string | SetStatusArguments) => Promise; + +export function createSetStatus(client: WebClient, channelId: string, threadTs: string): SetStatusFn { + return (status: string | SetStatusArguments) => { + if (typeof status === 'string') { + return client.assistant.threads.setStatus({ + channel_id: channelId, + thread_ts: threadTs, + status, + }); + } + return client.assistant.threads.setStatus({ + channel_id: channelId, + thread_ts: threadTs, + ...status, + }); + }; +} diff --git a/src/context/index.ts b/src/context/index.ts index b4519d5a7..fe0f52883 100644 --- a/src/context/index.ts +++ b/src/context/index.ts @@ -1,6 +1,8 @@ export { createSay } from './create-say'; export { createSayStream } from './create-say-stream'; export type { SayStreamFn, SayStreamArguments } from './create-say-stream'; +export { createSetStatus } from './create-set-status'; +export type { SetStatusFn, SetStatusArguments } from './create-set-status'; export { createRespond } from './create-respond'; export { createFunctionComplete } from './create-function-complete'; export { createFunctionFail } from './create-function-fail'; diff --git a/src/index.ts b/src/index.ts index 1afc52d68..2f20aeef2 100644 --- a/src/index.ts +++ b/src/index.ts @@ -53,6 +53,7 @@ export * from './errors'; export * from './middleware/builtin'; export * from './types'; export type { SayStreamFn, SayStreamArguments } from './context/create-say-stream'; +export type { SetStatusFn, SetStatusArguments } from './context/create-set-status'; export { ConversationStore, MemoryStore } from './conversation-store'; diff --git a/src/types/events/index.ts b/src/types/events/index.ts index fcd537d0d..b9c28e539 100644 --- a/src/types/events/index.ts +++ b/src/types/events/index.ts @@ -1,6 +1,7 @@ import type { FunctionExecutedEvent, SlackEvent } from '@slack/types'; import type { FunctionCompleteFn, FunctionFailFn } from '../../CustomFunction'; import type { SayStreamFn } from '../../context/create-say-stream'; +import type { SetStatusFn } from '../../context/create-set-status'; import type { AckFn, SayFn, StringIndexed } from '../utilities'; export type SlackEventMiddlewareArgsOptions = { autoAcknowledge: boolean }; @@ -49,7 +50,7 @@ export type SlackEventMiddlewareArgs = { : unknown) & (EventFromType extends EventWithChannelContext ? EventFromType extends EventWithThreadTsContext | EventWithTsContext - ? { sayStream: SayStreamFn } + ? { sayStream: SayStreamFn; setStatus: SetStatusFn } : unknown : unknown) & (EventType extends 'function_executed' diff --git a/test/types/set-status.test-d.ts b/test/types/set-status.test-d.ts new file mode 100644 index 000000000..1bd712b5f --- /dev/null +++ b/test/types/set-status.test-d.ts @@ -0,0 +1,71 @@ +import { expectType } from 'tsd'; +import type { SetStatusFn } from '../../'; +import App from '../../src/App'; + +const app = new App({ token: 'TOKEN', signingSecret: 'Signing Secret' }); + +app.event('message', async ({ setStatus }) => { + expectType(setStatus); +}); + +app.event('app_mention', async ({ setStatus }) => { + expectType(setStatus); +}); + +app.event('assistant_thread_started', async ({ setStatus }) => { + expectType(setStatus); +}); + +app.message('*', async ({ setStatus }) => { + expectType(setStatus); +}); + +app.event('reaction_added', async (args) => { + // @ts-expect-error - setStatus not available without ts context + const _status: SetStatusFn = args.setStatus; +}); + +app.event('app_home_opened', async (args) => { + // @ts-expect-error - setStatus not available without ts context + const _status: SetStatusFn = args.setStatus; +}); + +app.event('message_metadata_posted', async (args) => { + // @ts-expect-error - setStatus not available without ts context + const _status: SetStatusFn = args.setStatus; +}); + +app.event('channel_created', async (args) => { + // @ts-expect-error - setStatus not available without ts context + const _status: SetStatusFn = args.setStatus; +}); + +app.event('user_huddle_changed', async (args) => { + // @ts-expect-error - setStatus should not exist on events without channel context + const _status: SetStatusFn = args.setStatus; +}); + +app.action('button_click', async (args) => { + // @ts-expect-error - setStatus should not exist on action listeners + const _status: SetStatusFn = args.setStatus; +}); + +app.command('/slash-command', async (args) => { + // @ts-expect-error - setStatus should not exist on command listeners + const _status: SetStatusFn = args.setStatus; +}); + +app.function('sample-func', async (args) => { + // @ts-expect-error - setStatus should not exist on function listeners + const _status: SetStatusFn = args.setStatus; +}); + +app.view('my-view', async (args) => { + // @ts-expect-error - setStatus should not exist on view listeners + const _status: SetStatusFn = args.setStatus; +}); + +app.options('my-options', async (args) => { + // @ts-expect-error - setStatus should not exist on option listeners + const _status: SetStatusFn = args.setStatus; +}); diff --git a/test/unit/App/middlewares/arguments.spec.ts b/test/unit/App/middlewares/arguments.spec.ts index 6185079a2..d28b16d65 100644 --- a/test/unit/App/middlewares/arguments.spec.ts +++ b/test/unit/App/middlewares/arguments.spec.ts @@ -3,6 +3,7 @@ import { assert } from 'chai'; import sinon, { type SinonSpy } from 'sinon'; import { LogLevel } from '../../../../src/App'; import type { SayStreamFn } from '../../../../src/context/create-say-stream'; +import type { SetStatusFn } from '../../../../src/context/create-set-status'; import type { ReceiverEvent, SayFn } from '../../../../src/types'; import { FakeReceiver, @@ -25,6 +26,7 @@ import { withNoopAppMetadata, withNoopWebClient, withPostMessage, + withSetStatus, withSuccessfulBotUserFetchingWebClient, } from '../../helpers'; @@ -759,6 +761,65 @@ describe('App middleware and listener arguments', () => { }); }); + describe('setStatus()', () => { + it('should be available for events with channel and ts context', async () => { + const fakeSetStatus = sinon.fake.resolves({ ok: true }); + overrides = buildOverrides([withSetStatus(fakeSetStatus)]); + const MockApp = importApp(overrides); + + const assertionAggregator = sinon.fake(); + const app = new MockApp({ receiver: fakeReceiver, authorize: sinon.fake.resolves(dummyAuthorizationResult) }); + app.use(async (args) => { + // biome-ignore lint/suspicious/noExplicitAny: test utility + const setStatus = (args as any).setStatus as SetStatusFn; + assert.isFunction(setStatus); + assertionAggregator(); + }); + app.error(fakeErrorHandler); + + // Event with channel and ts (message event) + await fakeReceiver.sendEvent({ + ...baseEvent, + body: { + event: { + type: 'message', + channel: dummyChannelId, + ts: '1234.5678', + }, + team_id: 'TEAM_ID', + }, + }); + + sinon.assert.calledOnce(assertionAggregator); + sinon.assert.notCalled(fakeErrorHandler); + }); + + it('should not be available for events without channel context', async () => { + overrides = buildOverrides([withNoopWebClient()]); + const MockApp = importApp(overrides); + + const assertionAggregator = sinon.fake(); + const app = new MockApp({ receiver: fakeReceiver, authorize: sinon.fake.resolves(dummyAuthorizationResult) }); + app.use(async (args) => { + assert.notProperty(args, 'setStatus'); + assertionAggregator(); + }); + + // Event without channel context + await fakeReceiver.sendEvent({ + ...baseEvent, + body: { + event: { + type: 'tokens_revoked', + }, + team_id: 'TEAM_ID', + }, + }); + + sinon.assert.calledOnce(assertionAggregator); + }); + }); + describe('ack()', () => { it('should be available in middleware/listener args', async () => { const MockApp = importApp(overrides); diff --git a/test/unit/context/create-set-status.spec.ts b/test/unit/context/create-set-status.spec.ts new file mode 100644 index 000000000..4709e19d0 --- /dev/null +++ b/test/unit/context/create-set-status.spec.ts @@ -0,0 +1,56 @@ +import { WebClient } from '@slack/web-api'; +import { assert } from 'chai'; +import sinon from 'sinon'; +import { createSetStatus } from '../../../src/context'; + +describe('createSetStatus', () => { + const sandbox = sinon.createSandbox(); + const client = new WebClient('token'); + let setStatusStub: sinon.SinonStub; + + beforeEach(() => { + setStatusStub = sandbox.stub(client.assistant.threads, 'setStatus').resolves({ + ok: true, + }); + }); + + afterEach(() => { + sandbox.restore(); + }); + + it('should call client.assistant.threads.setStatus with string status', () => { + const setStatus = createSetStatus(client, 'C1234', '1234.5678'); + setStatus('is thinking...'); + + assert(setStatusStub.calledOnce); + const args = setStatusStub.firstCall.args[0]; + + assert.equal(args.channel_id, 'C1234'); + assert.equal(args.thread_ts, '1234.5678'); + assert.equal(args.status, 'is thinking...'); + }); + + it('should call client.assistant.threads.setStatus with object status', () => { + const setStatus = createSetStatus(client, 'C1234', '1234.5678'); + setStatus({ status: 'is thinking...', loading_messages: ['Loading...', 'Still working...'] }); + + assert(setStatusStub.calledOnce); + const args = setStatusStub.firstCall.args[0]; + + assert.equal(args.channel_id, 'C1234'); + assert.equal(args.thread_ts, '1234.5678'); + assert.equal(args.status, 'is thinking...'); + assert.deepEqual(args.loading_messages, ['Loading...', 'Still working...']); + }); + + it('should use the channel_id and thread_ts from factory creation', () => { + const setStatus = createSetStatus(client, 'C9999', '9999.0000'); + setStatus('processing'); + + assert(setStatusStub.calledOnce); + const args = setStatusStub.firstCall.args[0]; + + assert.equal(args.channel_id, 'C9999'); + assert.equal(args.thread_ts, '9999.0000'); + }); +}); diff --git a/test/unit/helpers/app.ts b/test/unit/helpers/app.ts index f1115290d..11aa076e3 100644 --- a/test/unit/helpers/app.ts +++ b/test/unit/helpers/app.ts @@ -117,6 +117,20 @@ export function withChatStream(spy: SinonSpy): Override { }; } +export function withSetStatus(spy: SinonSpy): Override { + return { + '@slack/web-api': { + WebClient: class { + public assistant = { + threads: { + setStatus: spy, + }, + }; + }, + }, + }; +} + export function withAxiosPost(spy: SinonSpy): Override { return { axios: { diff --git a/test/unit/helpers/events.ts b/test/unit/helpers/events.ts index 4ce89c971..1576bf43f 100644 --- a/test/unit/helpers/events.ts +++ b/test/unit/helpers/events.ts @@ -19,6 +19,7 @@ import type { } from '../../../src/Assistant'; import type { SlackCustomFunctionMiddlewareArgs } from '../../../src/CustomFunction'; import type { SayStreamFn } from '../../../src/context/create-say-stream'; +import type { SetStatusFn } from '../../../src/context/create-set-status'; import type { AckFn, AllMiddlewareArgs, @@ -56,6 +57,7 @@ const token = 'xoxb-1234'; const app_id = 'A1234'; const say: SayFn = (_msg) => Promise.resolve({ ok: true }); const sayStream: SayStreamFn = (_args?) => ({}) as ReturnType; +const setStatus: SetStatusFn = (_status) => Promise.resolve({ ok: true }); const respond: RespondFn = (_msg) => Promise.resolve(); const ack: AckFn = (_r?) => Promise.resolve(); @@ -188,6 +190,7 @@ export function createDummyMessageEventMiddlewareArgs( body: envelopeEvent(payload, bodyOverrides), say, sayStream, + setStatus, }; } @@ -213,6 +216,7 @@ export function createDummyAppMentionEventMiddlewareArgs( body: envelopeEvent(payload, bodyOverrides), say, sayStream, + setStatus, }; } function enrichDummyAssistantMiddlewareArgs() {