Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 12 additions & 2 deletions src/App.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -1054,6 +1061,8 @@ export default class App<AppCustomContext extends StringIndexed = StringIndexed>
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 */
Expand Down Expand Up @@ -1122,6 +1131,7 @@ export default class App<AppCustomContext extends StringIndexed = StringIndexed>
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) {
Expand Down
30 changes: 6 additions & 24 deletions src/Assistant.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,4 @@
import type {
AssistantThreadsSetStatusArguments,
AssistantThreadsSetStatusResponse,
AssistantThreadsSetSuggestedPromptsResponse,
AssistantThreadsSetTitleResponse,
ChatPostMessageArguments,
Expand All @@ -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';
Expand Down Expand Up @@ -43,9 +42,6 @@ interface AssistantUtilityArgs {

type GetThreadContextUtilFn = () => Promise<AssistantThreadContext>;
type SaveThreadContextUtilFn = () => Promise<void>;
type SetStatusFn = (
status: string | Omit<AssistantThreadsSetStatusArguments, 'channel_id' | 'thread_ts'>,
) => Promise<AssistantThreadsSetStatusResponse>;

type SetSuggestedPromptsFn = (
params: SetSuggestedPromptsArguments,
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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<SetStatusFn>[0]): Promise<AssistantThreadsSetStatusResponse> => {
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);
Comment on lines +349 to +352

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎨 praise: Beautiful implementation for extending this to other listeners!

}

/**
Expand Down
22 changes: 22 additions & 0 deletions src/context/create-set-status.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import type { AssistantThreadsSetStatusArguments, AssistantThreadsSetStatusResponse, WebClient } from '@slack/web-api';

export type SetStatusArguments = Omit<AssistantThreadsSetStatusArguments, 'channel_id' | 'thread_ts'>;

export type SetStatusFn = (status: string | SetStatusArguments) => Promise<AssistantThreadsSetStatusResponse>;

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,
});
};
}
2 changes: 2 additions & 0 deletions src/context/index.ts
Original file line number Diff line number Diff line change
@@ -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';
1 change: 1 addition & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down
3 changes: 2 additions & 1 deletion src/types/events/index.ts
Original file line number Diff line number Diff line change
@@ -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 };
Expand Down Expand Up @@ -49,7 +50,7 @@ export type SlackEventMiddlewareArgs<EventType extends string = string> = {
: unknown) &
(EventFromType<EventType> extends EventWithChannelContext
? EventFromType<EventType> extends EventWithThreadTsContext | EventWithTsContext
? { sayStream: SayStreamFn }
? { sayStream: SayStreamFn; setStatus: SetStatusFn }
: unknown
: unknown) &
(EventType extends 'function_executed'
Expand Down
71 changes: 71 additions & 0 deletions test/types/set-status.test-d.ts
Original file line number Diff line number Diff line change
@@ -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<SetStatusFn>(setStatus);
});

app.event('app_mention', async ({ setStatus }) => {
expectType<SetStatusFn>(setStatus);
});

app.event('assistant_thread_started', async ({ setStatus }) => {
expectType<SetStatusFn>(setStatus);
});

app.message('*', async ({ setStatus }) => {
expectType<SetStatusFn>(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;
});
61 changes: 61 additions & 0 deletions test/unit/App/middlewares/arguments.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -25,6 +26,7 @@ import {
withNoopAppMetadata,
withNoopWebClient,
withPostMessage,
withSetStatus,
withSuccessfulBotUserFetchingWebClient,
} from '../../helpers';

Expand Down Expand Up @@ -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);
Expand Down
56 changes: 56 additions & 0 deletions test/unit/context/create-set-status.spec.ts
Original file line number Diff line number Diff line change
@@ -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');
});
});
14 changes: 14 additions & 0 deletions test/unit/helpers/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down
Loading