Repository: slackapi/bolt-js
Pull request #1448 (2022-05-10 16:14:50)

Adding support for new user-change events with types
These are UserHuddleChangedEvent, UserProfileChangedEvent, UserStatusChangedEvent.

I assume this is a minor version bump but maybe not? Not sure if a new type counts as an API addition? 🤔 

codecov[bot]: # [Codecov](https://codecov.io/gh/slackapi/bolt-js/pull/1448?src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=slackapi) Report
> Merging [#1448](https://codecov.io/gh/slackapi/bolt-js/pull/1448?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=slackapi) (921db48) into [main](https://codecov.io/gh/slackapi/bolt-js/commit/0c996d08bedb1445c4d92057bca422dc5f9b84b4?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=slackapi) (0c996d0) will **not change** coverage.
> The diff coverage is `n/a`.

```diff
@@           Coverage Diff           @@
##             main    #1448   +/-   ##
=======================================
  Coverage   81.97%   81.97%           
=======================================
  Files          18       18           
  Lines        1476     1476           
  Branches      434      434           
=======================================
  Hits         1210     1210           
  Misses        172      172           
  Partials       94       94           
```



------

[Continue to review full report at Codecov](https://codecov.io/gh/slackapi/bolt-js/pull/1448?src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=slackapi).
> **Legend** - [Click here to learn more](https://docs.codecov.io/docs/codecov-delta?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=slackapi)
> `Δ = absolute <relative> (impact)`, `ø = not affected`, `? = missing data`
> Powered by [Codecov](https://codecov.io/gh/slackapi/bolt-js/pull/1448?src=pr&el=footer&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=slackapi). Last update [0c996d0...921db48](https://codecov.io/gh/slackapi/bolt-js/pull/1448?src=pr&el=lastupdated&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=slackapi). Read the [comment docs](https://docs.codecov.io/docs/pull-request-comments?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=slackapi).

mwbrooks: > I assume this is a minor version bump but maybe not? Not sure if a new type counts as an API addition? 🤔

I'd also consider this a `.minor` because we're adding support for the event types - a feature that didn't exist previously. But I'm also curious how we've versioned these changes in the past!
filmaj: > But I'm also curious how we've versioned these changes in the past!

I looked to @srajiang's past PR as a guide before I tackled this: https://github.com/slackapi/bolt-js/pull/1008 - it looks like that was a minor bump back then.
filmaj: Yeah... wow @srajiang I am now testing these events with an app and there are.. a lot of differences. I will try to address them in forthcoming commits as best I can...

Patch:
diff --git a/docs/_includes/sidebar.html b/docs/_includes/sidebar.html
index 30108f7..6e718fb 100644
--- a/docs/_includes/sidebar.html
+++ b/docs/_includes/sidebar.html
@@ -67,10 +67,8 @@
 
   {% assign deployments_sections = site.deployments | sort: "order" | where: "lang", page.lang %}
   {% if deployments_sections.size > 0 %}
-    <ul class="sidebar-section">
-      <a href="{{ site.url | append: site.baseurl | append: localized_base_url }}/deployments">
+    <ul class="sidebar-section"> 
         <li class="title">{{ site.t[page.lang].deployments }}</li>
-      </a>
       {% for section in deployments_sections %}
         <a href="{{ site.url | append: site.baseurl | append: localized_base_url }}/deployments/{{ section.slug }}">
           <li>{{section.title}}</li>
diff --git a/src/receivers/AwsLambdaReceiver.ts b/src/receivers/AwsLambdaReceiver.ts
index d93736b..c276783 100644
--- a/src/receivers/AwsLambdaReceiver.ts
+++ b/src/receivers/AwsLambdaReceiver.ts
@@ -154,7 +154,7 @@ export default class AwsLambdaReceiver implements Receiver {
 
       // Setup ack timeout warning
       let isAcknowledged = false;
-      setTimeout(() => {
+      const noAckTimeoutId = setTimeout(() => {
         if (!isAcknowledged) {
           this.logger.error(
             'An incoming event was not acknowledged within 3 seconds. ' +
@@ -172,6 +172,7 @@ export default class AwsLambdaReceiver implements Receiver {
             throw new ReceiverMultipleAckError();
           }
           isAcknowledged = true;
+          clearTimeout(noAckTimeoutId);
           if (typeof response === 'undefined' || response == null) {
             storedResponse = '';
           } else {
diff --git a/src/receivers/HTTPResponseAck.spec.ts b/src/receivers/HTTPResponseAck.spec.ts
index 1072b1b..c58389a 100644
--- a/src/receivers/HTTPResponseAck.spec.ts
+++ b/src/receivers/HTTPResponseAck.spec.ts
@@ -39,6 +39,25 @@ describe('HTTPResponseAck', async () => {
       done();
     }, 2);
   });
+  it('should not trigger unhandledRequestHandler if acknowledged', (done) => {
+    const httpRequest = sinon.createStubInstance(IncomingMessage) as IncomingMessage;
+    const httpResponse: ServerResponse = sinon.createStubInstance(ServerResponse) as unknown as ServerResponse;
+    const spy = sinon.spy();
+    // eslint-disable-next-line no-new
+    const responseAck = new HTTPResponseAck({
+      logger: createFakeLogger(),
+      processBeforeResponse: false,
+      unhandledRequestTimeoutMillis: 1,
+      unhandledRequestHandler: spy,
+      httpRequest,
+      httpResponse,
+    });
+    responseAck.ack();
+    setTimeout(() => {
+      assert(spy.notCalled);
+      done();
+    }, 2);
+  });
   it('should throw an error if a bound Ack invocation was already acknowledged', async () => {
     const httpRequest = sinon.createStubInstance(IncomingMessage) as IncomingMessage;
     const httpResponse: ServerResponse = sinon.createStubInstance(ServerResponse) as unknown as ServerResponse;
diff --git a/src/receivers/HTTPResponseAck.ts b/src/receivers/HTTPResponseAck.ts
index 326fb85..24eb697 100644
--- a/src/receivers/HTTPResponseAck.ts
+++ b/src/receivers/HTTPResponseAck.ts
@@ -31,6 +31,8 @@ export class HTTPResponseAck {
 
   private httpResponse: ServerResponse;
 
+  private noAckTimeoutId?: NodeJS.Timeout;
+
   // eslint-disable-next-line @typescript-eslint/no-explicit-any
   public storedResponse: any | string | undefined;
 
@@ -43,11 +45,12 @@ export class HTTPResponseAck {
     this.httpRequest = args.httpRequest;
     this.httpResponse = args.httpResponse;
     this.storedResponse = undefined;
+    this.noAckTimeoutId = undefined;
     this.init();
   }
 
   private init(): HTTPResponseAck {
-    setTimeout(() => {
+    this.noAckTimeoutId = setTimeout(() => {
       if (!this.isAcknowledged) {
         this.unhandledRequestHandler({
           logger: this.logger,
@@ -84,5 +87,8 @@ export class HTTPResponseAck {
 
   public ack(): void {
     this.isAcknowledged = true;
+    if (this.noAckTimeoutId) {
+      clearTimeout(this.noAckTimeoutId);
+    }
   }
 }
diff --git a/src/types/events/base-events.ts b/src/types/events/base-events.ts
index 5ca5f91..57eccba 100644
--- a/src/types/events/base-events.ts
+++ b/src/types/events/base-events.ts
@@ -76,6 +76,9 @@ export type SlackEvent =
   | TeamRenameEvent
   | TokensRevokedEvent
   | UserChangeEvent
+  | UserHuddleChangedEvent
+  | UserProfileChangedEvent
+  | UserStatusChangedEvent
   | WorkflowDeletedEvent
   | WorkflowPublishedEvent
   | WorkflowUnpublishedEvent
@@ -859,6 +862,12 @@ export interface TokensRevokedEvent {
 
 // NOTE: url_verification does not use the envelope, but its also not interesting for an app developer. its omitted.
 
+export interface StatusEmojiDisplayInfo {
+  emoji_name?: string;
+  display_alias?: string;
+  display_url?: string;
+}
+
 export interface UserChangeEvent {
   type: 'user_change';
   user: {
@@ -882,11 +891,258 @@ export interface UserChangeEvent {
       status_text: string;
       status_text_canonical: string;
       status_emoji: string;
+      status_emoji_display_info: StatusEmojiDisplayInfo[];
+      status_expiration: number;
+      avatar_hash: string;
+      huddle_state?: string;
+      huddle_state_expiration_ts?: number;
+      first_name: string;
+      last_name: string;
+      email?: string;
+      image_original?: string;
+      is_custom_image?: boolean;
+      image_24: string;
+      image_32: string;
+      image_48: string;
+      image_72: string;
+      image_192: string;
+      image_512: string;
+      image_1024?: string;
+      team: string;
+      fields:
+      | {
+        [key: string]: {
+          value: string;
+          alt: string;
+        };
+      }
+      | []
+      | null;
+    };
+    is_admin: boolean;
+    is_owner: boolean;
+    is_primary_owner: boolean;
+    is_restricted: boolean;
+    is_ultra_restricted: boolean;
+    is_bot: boolean;
+    is_stranger?: boolean;
+    updated: number;
+    is_email_confirmed: boolean;
+    is_app_user: boolean;
+    is_invited_user?: boolean;
+    has_2fa?: boolean;
+    locale: string;
+    presence?: string;
+    enterprise_user?: {
+      id: string;
+      enterprise_id: string;
+      enterprise_name: string;
+      is_admin: boolean;
+      is_owner: boolean;
+      teams: string[];
+    };
+    two_factor_type?: string;
+    has_files?: boolean;
+    is_workflow_bot?: boolean;
+    who_can_share_contact_card: string;
+  };
+  cache_ts: number;
+  event_ts: string;
+}
+
+export interface UserHuddleChangedEvent {
+  type: 'user_huddle_changed';
+  user: {
+    id: string;
+    team_id: string;
+    name: string;
+    deleted: boolean;
+    color: string;
+    real_name: string;
+    tz: string;
+    tz_label: string;
+    tz_offset: number;
+    profile: {
+      title: string;
+      phone: string;
+      skype: string;
+      real_name: string;
+      real_name_normalized: string;
+      display_name: string;
+      display_name_normalized: string;
+      status_text: string;
+      status_text_canonical: string;
+      status_emoji: string;
+      status_emoji_display_info: StatusEmojiDisplayInfo[];
+      status_expiration: number;
+      avatar_hash: string;
+      huddle_state: string;
+      huddle_state_expiration_ts: number;
+      first_name: string;
+      last_name: string;
+      email?: string;
+      image_original?: string;
+      is_custom_image?: boolean;
+      image_24: string;
+      image_32: string;
+      image_48: string;
+      image_72: string;
+      image_192: string;
+      image_512: string;
+      image_1024?: string;
+      team: string;
+      fields:
+      | {
+        [key: string]: {
+          value: string;
+          alt: string;
+        };
+      }
+      | []
+      | null;
+    };
+    is_admin: boolean;
+    is_owner: boolean;
+    is_primary_owner: boolean;
+    is_restricted: boolean;
+    is_ultra_restricted: boolean;
+    is_bot: boolean;
+    is_stranger?: boolean;
+    updated: number;
+    is_email_confirmed: boolean;
+    is_app_user: boolean;
+    is_invited_user?: boolean;
+    has_2fa?: boolean;
+    locale: string;
+    presence?: string;
+    enterprise_user?: {
+      id: string;
+      enterprise_id: string;
+      enterprise_name: string;
+      is_admin: boolean;
+      is_owner: boolean;
+      teams: string[];
+    };
+    two_factor_type?: string;
+    has_files?: boolean;
+    is_workflow_bot?: boolean;
+    who_can_share_contact_card: string;
+  };
+  cache_ts: number;
+  event_ts: string;
+}
+
+export interface UserProfileChangedEvent {
+  type: 'user_profile_changed';
+  user: {
+    id: string;
+    team_id: string;
+    name: string;
+    deleted: boolean;
+    color: string;
+    real_name: string;
+    tz: string;
+    tz_label: string;
+    tz_offset: number;
+    profile: {
+      title: string;
+      phone: string;
+      skype: string;
+      real_name: string;
+      real_name_normalized: string;
+      display_name: string;
+      display_name_normalized: string;
+      status_text: string;
+      status_text_canonical: string;
+      status_emoji: string;
+      status_emoji_display_info: StatusEmojiDisplayInfo[];
+      status_expiration: number;
+      avatar_hash: string;
+      huddle_state: string;
+      huddle_state_expiration_ts: number;
+      first_name: string;
+      last_name: string;
+      email?: string;
+      image_original?: string;
+      is_custom_image?: boolean;
+      image_24: string;
+      image_32: string;
+      image_48: string;
+      image_72: string;
+      image_192: string;
+      image_512: string;
+      image_1024?: string;
+      team: string;
+      fields:
+      | {
+        [key: string]: {
+          value: string;
+          alt: string;
+        };
+      }
+      | []
+      | null;
+    };
+    is_admin: boolean;
+    is_owner: boolean;
+    is_primary_owner: boolean;
+    is_restricted: boolean;
+    is_ultra_restricted: boolean;
+    is_bot: boolean;
+    is_stranger?: boolean;
+    updated: number;
+    is_email_confirmed: boolean;
+    is_app_user: boolean;
+    is_invited_user?: boolean;
+    has_2fa?: boolean;
+    locale: string;
+    presence?: string;
+    enterprise_user?: {
+      id: string;
+      enterprise_id: string;
+      enterprise_name: string;
+      is_admin: boolean;
+      is_owner: boolean;
+      teams: string[];
+    };
+    two_factor_type?: string;
+    has_files?: boolean;
+    is_workflow_bot?: boolean;
+    who_can_share_contact_card: string;
+  };
+  cache_ts: number;
+  event_ts: string;
+}
+
+export interface UserStatusChangedEvent {
+  type: 'user_status_changed';
+  user: {
+    id: string;
+    team_id: string;
+    name: string;
+    deleted: boolean;
+    color: string;
+    real_name: string;
+    tz: string;
+    tz_label: string;
+    tz_offset: number;
+    profile: {
+      title: string;
+      phone: string;
+      skype: string;
+      real_name: string;
+      real_name_normalized: string;
+      display_name: string;
+      display_name_normalized: string;
+      status_text: string;
+      status_text_canonical: string;
+      status_emoji: string;
+      status_emoji_display_info: StatusEmojiDisplayInfo[],
       status_expiration: number;
       avatar_hash: string;
       first_name: string;
       last_name: string;
-      email: string;
+      email?: string;
       image_original?: string;
       is_custom_image?: boolean;
       image_24: string;
@@ -920,7 +1176,7 @@ export interface UserChangeEvent {
     is_invited_user?: boolean;
     has_2fa?: boolean;
     locale: string;
-    presence: string;
+    presence?: string;
     enterprise_user?: {
       id: string;
       enterprise_id: string;
@@ -929,12 +1185,13 @@ export interface UserChangeEvent {
       is_owner: boolean;
       teams: string[];
     };
-    two_factor_type: string;
-    has_files: boolean;
-    is_workflow_bot: boolean;
-    who_can_share_contact_card: boolean;
+    two_factor_type?: string;
+    has_files?: boolean;
+    is_workflow_bot?: boolean;
+    who_can_share_contact_card: string;
   };
   cache_ts: number;
+  event_ts: string;
 }
 
 export interface WorkflowDeletedEvent {


Test patch:
diff --git a/types-tests/event.test-d.ts b/types-tests/event.test-d.ts
index 4090774..8bc1f5a 100644
--- a/types-tests/event.test-d.ts
+++ b/types-tests/event.test-d.ts
@@ -1,5 +1,5 @@
 import { expectNotType, expectType } from 'tsd';
-import { App, SlackEvent, AppMentionEvent, ReactionAddedEvent, ReactionRemovedEvent } from '..';
+import { App, SlackEvent, AppMentionEvent, ReactionAddedEvent, ReactionRemovedEvent, UserHuddleChangedEvent, UserProfileChangedEvent, UserStatusChangedEvent } from '..';
 
 const app = new App({ token: 'TOKEN', signingSecret: 'Signing Secret' });
 
@@ -26,3 +26,27 @@ expectType<void>(
     await Promise.resolve(event);
   })
 );
+
+expectType<void>(
+  app.event('user_huddle_changed', async ({ event }) => {
+    expectType<UserHuddleChangedEvent>(event);
+    expectNotType<SlackEvent>(event);
+    await Promise.resolve(event);
+  })
+);
+
+expectType<void>(
+  app.event('user_profile_changed', async ({ event }) => {
+    expectType<UserProfileChangedEvent>(event);
+    expectNotType<SlackEvent>(event);
+    await Promise.resolve(event);
+  })
+);
+
+expectType<void>(
+  app.event('user_status_changed', async ({ event }) => {
+    expectType<UserStatusChangedEvent>(event);
+    expectNotType<SlackEvent>(event);
+    await Promise.resolve(event);
+  })
+);