Repository: Yoctol/bottender
Pull request #47 (2017-11-27 10:30:22)

Implement custom accessToken

chentsulin: We should make this also work with send actions (markSeen, typingOn, typingOff) and label apis.
chentsulin: Just an idea, should we renamed `getAccessToken` as `mapPageToAceessToken`?

Patch:
diff --git a/src/bot/Bot.js b/src/bot/Bot.js
index 8d10be0b..8e3fc8a3 100644
--- a/src/bot/Bot.js
+++ b/src/bot/Bot.js
@@ -1,6 +1,7 @@
 /* @flow */
 
 import _debug from 'debug';
+import pMap from 'p-map';
 
 import MemoryCacheStore from '../cache/MemoryCacheStore';
 import CacheBasedSessionStore from '../session/CacheBasedSessionStore';
@@ -132,12 +133,17 @@ export default class Bot {
 
       const events = this._connector.mapRequestToEvents(body);
 
-      const contexts = events.map(event =>
-        this._connector.createContext({
-          event,
-          session: ((session: any): Session),
-          initialState: this._initialState,
-        })
+      const contexts = await pMap(
+        events,
+        event =>
+          this._connector.createContext({
+            event,
+            session: ((session: any): Session),
+            initialState: this._initialState,
+          }),
+        {
+          concurrency: 5,
+        }
       );
 
       // Call all of extension functions before passing to handler.
diff --git a/src/bot/MessengerBot.js b/src/bot/MessengerBot.js
index 37088534..aa6f4ff7 100644
--- a/src/bot/MessengerBot.js
+++ b/src/bot/MessengerBot.js
@@ -11,13 +11,19 @@ export default class MessengerBot extends Bot {
     appSecret,
     sessionStore,
     sync,
+    mapPageToAccessToken,
   }: {
     accessToken: string,
     appSecret: string,
     sessionStore: SessionStore,
     sync?: boolean,
+    mapPageToAccessToken?: (pageId: string) => Promise<string>,
   }) {
-    const connector = new MessengerConnector({ accessToken, appSecret });
+    const connector = new MessengerConnector({
+      accessToken,
+      appSecret,
+      mapPageToAccessToken,
+    });
     super({ connector, sessionStore, sync });
   }
 }
diff --git a/src/bot/MessengerConnector.js b/src/bot/MessengerConnector.js
index 7d77e0fe..2a2b89ad 100644
--- a/src/bot/MessengerConnector.js
+++ b/src/bot/MessengerConnector.js
@@ -77,16 +77,24 @@ type ConstructorOptions = {|
   accessToken?: string,
   appSecret?: string,
   client?: MessengerClient,
+  mapPageToAccessToken?: (pageId: string) => Promise<string>,
 |};
 
 export default class MessengerConnector
   implements Connector<MessengerRequestBody> {
   _client: MessengerClient;
   _appSecret: ?string;
+  _mapPageToAccessToken: ?(pageId: string) => ?Promise<string>;
 
-  constructor({ accessToken, appSecret, client }: ConstructorOptions) {
+  constructor({
+    accessToken,
+    appSecret,
+    client,
+    mapPageToAccessToken,
+  }: ConstructorOptions) {
     this._client = client || MessengerClient.connect(accessToken);
     this._appSecret = appSecret;
+    this._mapPageToAccessToken = mapPageToAccessToken;
     if (!this._appSecret) {
       warning(
         false,
@@ -164,8 +172,33 @@ export default class MessengerConnector
   ): Promise<void> {
     if (!session.user || this._profilePicExpired(session.user)) {
       const senderId = this.getUniqueSessionKey(body);
+
+      let customAccessToken;
+      if (this._mapPageToAccessToken != null) {
+        const mapPageToAccessToken = this._mapPageToAccessToken;
+
+        const rawEvent = this._getRawEventsFromRequest(body)[0];
+
+        let pageId;
+
+        if (rawEvent.message && rawEvent.message.is_echo && rawEvent.sender) {
+          pageId = rawEvent.sender.id;
+        } else if (rawEvent.recipient) {
+          pageId = rawEvent.recipient.id;
+        }
+
+        if (!pageId) {
+          warning(false, 'Could not find pageId from request body.');
+        } else {
+          customAccessToken = await mapPageToAccessToken(pageId);
+        }
+      }
+
       // FIXME: refine user
-      const user = await this._client.getUserProfile(senderId);
+      const user = await this._client.getUserProfile(senderId, {
+        access_token: customAccessToken,
+      });
+
       session.user = {
         _updatedAt: new Date().toISOString(),
         ...user,
@@ -193,7 +226,7 @@ export default class MessengerConnector
     return rawEvents.map(event => new MessengerEvent(event, { isStandby }));
   }
 
-  createContext({
+  async createContext({
     event,
     session,
     initialState,
@@ -201,12 +234,31 @@ export default class MessengerConnector
     event: MessengerEvent,
     session: ?Session,
     initialState: Object,
-  }): MessengerContext {
+  }): Promise<MessengerContext> {
+    let customAccessToken;
+    if (this._mapPageToAccessToken) {
+      const { rawEvent } = event;
+
+      let pageId = null;
+
+      if (rawEvent.message && rawEvent.message.is_echo && rawEvent.sender) {
+        pageId = rawEvent.sender.id;
+      } else if (rawEvent.recipient) {
+        pageId = rawEvent.recipient.id;
+      }
+
+      if (!pageId) {
+        warning(false, 'Could not find pageId from request body.');
+      } else {
+        customAccessToken = await this._mapPageToAccessToken(pageId);
+      }
+    }
     return new MessengerContext({
       client: this._client,
       event,
       session,
       initialState,
+      customAccessToken,
     });
   }
 
diff --git a/src/context/MessengerContext.js b/src/context/MessengerContext.js
index 470249f8..1e56f53d 100644
--- a/src/context/MessengerContext.js
+++ b/src/context/MessengerContext.js
@@ -15,15 +15,24 @@ type Options = {|
   event: MessengerEvent,
   session: ?Session,
   initialState: ?Object,
+  customAccessToken: ?string,
 |};
 
 class MessengerContext extends Context implements PlatformContext {
   _client: MessengerClient;
   _event: MessengerEvent;
   _session: ?Session;
-
-  constructor({ client, event, session, initialState }: Options) {
+  _customAccessToken: ?string;
+
+  constructor({
+    client,
+    event,
+    session,
+    initialState,
+    customAccessToken,
+  }: Options) {
     super({ client, event, session, initialState });
+    this._customAccessToken = customAccessToken;
   }
 
   /**
@@ -34,6 +43,10 @@ class MessengerContext extends Context implements PlatformContext {
     return 'messenger';
   }
 
+  get accessToken(): ?string {
+    return this._customAccessToken || this._client._accessToken; // FIXME
+  }
+
   /**
    * Delay and show indicators for milliseconds.
    *
@@ -66,6 +79,7 @@ class MessengerContext extends Context implements PlatformContext {
     return this._client.sendText(this._session.user.id, text, {
       messaging_type: messageType,
       ...options,
+      access_token: this._customAccessToken,
     });
   }
 
@@ -89,7 +103,9 @@ class MessengerContext extends Context implements PlatformContext {
 
     this._isHandled = true;
 
-    return this._client.typingOn(this._session.user.id);
+    return this._client.typingOn(this._session.user.id, {
+      access_token: this._customAccessToken,
+    });
   }
 
   /**
@@ -106,7 +122,9 @@ class MessengerContext extends Context implements PlatformContext {
 
     this._isHandled = true;
 
-    return this._client.typingOff(this._session.user.id);
+    return this._client.typingOff(this._session.user.id, {
+      access_token: this._customAccessToken,
+    });
   }
 
   /**
@@ -123,7 +141,9 @@ class MessengerContext extends Context implements PlatformContext {
 
     this._isHandled = true;
 
-    return this._client.markSeen(this._session.user.id);
+    return this._client.markSeen(this._session.user.id, {
+      access_token: this._customAccessToken,
+    });
   }
 
   /**
@@ -152,7 +172,10 @@ class MessengerContext extends Context implements PlatformContext {
     return this._client.passThreadControl(
       this._session.user.id,
       targetAppId,
-      metadata
+      metadata,
+      {
+        access_token: this._customAccessToken,
+      }
     );
   }
 
@@ -170,7 +193,9 @@ class MessengerContext extends Context implements PlatformContext {
 
     this._isHandled = true;
 
-    return this._client.passThreadControlToPageInbox(this._session.user.id);
+    return this._client.passThreadControlToPageInbox(this._session.user.id, {
+      access_token: this._customAccessToken,
+    });
   }
 
   /**
@@ -187,7 +212,9 @@ class MessengerContext extends Context implements PlatformContext {
 
     this._isHandled = true;
 
-    return this._client.takeThreadControl(this._session.user.id);
+    return this._client.takeThreadControl(this._session.user.id, {
+      access_token: this._customAccessToken,
+    });
   }
 
   /**
@@ -208,7 +235,9 @@ class MessengerContext extends Context implements PlatformContext {
       return;
     }
 
-    return this._client.associateLabel(this._session.user.id, labelId);
+    return this._client.associateLabel(this._session.user.id, labelId, {
+      access_token: this._customAccessToken,
+    });
   }
 
   /**
@@ -223,7 +252,9 @@ class MessengerContext extends Context implements PlatformContext {
       return;
     }
 
-    return this._client.dissociateLabel(this._session.user.id, labelId);
+    return this._client.dissociateLabel(this._session.user.id, labelId, {
+      access_token: this._customAccessToken,
+    });
   }
 
   /**
@@ -238,7 +269,9 @@ class MessengerContext extends Context implements PlatformContext {
       return;
     }
 
-    return this._client.getAssociatedLabels(this._session.user.id);
+    return this._client.getAssociatedLabels(this._session.user.id, {
+      access_token: this._customAccessToken,
+    });
   }
 }
 
@@ -286,6 +319,7 @@ sendMethods.forEach(([method, len]) => {
       args[len - 2] = {
         messaging_type: messageType,
         ...options,
+        access_token: this._customAccessToken,
       };
 
       return this._client[method](this._session.user.id, ...args);


Test patch:
diff --git a/src/bot/__tests__/Bot.spec.js b/src/bot/__tests__/Bot.spec.js
index 73c61853..7f71b06f 100644
--- a/src/bot/__tests__/Bot.spec.js
+++ b/src/bot/__tests__/Bot.spec.js
@@ -8,6 +8,7 @@ function setup({
     updateSession: jest.fn(),
     mapRequestToEvents: jest.fn(() => [{}]),
     createContext: jest.fn(() => ({})),
+    customAccessToken: 'anyToken',
   },
   sessionStore = {
     init: jest.fn(),
@@ -15,13 +16,15 @@ function setup({
     write: jest.fn(),
   },
   sync = false,
+  mapPageToAccessToken,
 }) {
-  const bot = new Bot({ connector, sessionStore, sync });
+  const bot = new Bot({ connector, sessionStore, sync, mapPageToAccessToken });
 
   return {
     bot,
     connector,
     sessionStore,
+    mapPageToAccessToken,
   };
 }
 
diff --git a/src/bot/__tests__/MessengerConnector.spec.js b/src/bot/__tests__/MessengerConnector.spec.js
index cfcae5de..5cc08672 100644
--- a/src/bot/__tests__/MessengerConnector.spec.js
+++ b/src/bot/__tests__/MessengerConnector.spec.js
@@ -144,9 +144,10 @@ const standbyRequest = {
 };
 
 function setup(
-  { accessToken, appSecret } = {
+  { accessToken, appSecret, mapPageToAccessToken } = {
     accessToken: ACCESS_TOKEN,
     appSecret: APP_SECRET,
+    mapPageToAccessToken: jest.fn(),
   }
 ) {
   const mockGraphAPIClient = {
@@ -159,6 +160,7 @@ function setup(
     connector: new MessengerConnector({
       accessToken,
       appSecret,
+      mapPageToAccessToken,
     }),
   };
 }
@@ -227,7 +229,8 @@ describe('#updateSession', () => {
     await connector.updateSession(session, request.body);
 
     expect(mockGraphAPIClient.getUserProfile).toBeCalledWith(
-      '1412611362105802'
+      '1412611362105802',
+      { access_token: undefined }
     );
     expect(session).toEqual({
       user: {
@@ -258,7 +261,8 @@ describe('#updateSession', () => {
     await connector.updateSession(session, request.body);
 
     expect(mockGraphAPIClient.getUserProfile).toBeCalledWith(
-      '1412611362105802'
+      '1412611362105802',
+      { access_token: undefined }
     );
     expect(session).toEqual({
       user: {
@@ -289,7 +293,8 @@ describe('#updateSession', () => {
     await connector.updateSession(session, request.body);
 
     expect(mockGraphAPIClient.getUserProfile).toBeCalledWith(
-      '1412611362105802'
+      '1412611362105802',
+      { access_token: undefined }
     );
     expect(session).toEqual({
       user: {
@@ -320,7 +325,8 @@ describe('#updateSession', () => {
     await connector.updateSession(session, request.body);
 
     expect(mockGraphAPIClient.getUserProfile).toBeCalledWith(
-      '1412611362105802'
+      '1412611362105802',
+      { access_token: undefined }
     );
     expect(session).toEqual({
       user: {
@@ -388,12 +394,18 @@ describe('#mapRequestToEvents', () => {
 });
 
 describe('#createContext', () => {
-  it('should create MessengerContext', () => {
+  it('should create MessengerContext', async () => {
     const { connector } = setup();
-    const event = {};
+    const event = {
+      rawEvent: {
+        recipient: {
+          id: 'anyPageId',
+        },
+      },
+    };
     const session = {};
 
-    const context = connector.createContext({
+    const context = await connector.createContext({
       event,
       session,
     });
@@ -401,6 +413,27 @@ describe('#createContext', () => {
     expect(context).toBeDefined();
     expect(context).toBeInstanceOf(MessengerContext);
   });
+
+  it('should create MessengerContext and has customAccessToken', async () => {
+    const mapPageToAccessToken = jest.fn(() => Promise.resolve('anyToken'));
+    const { connector } = setup({ mapPageToAccessToken });
+    const event = {
+      rawEvent: {
+        recipient: {
+          id: 'anyPageId',
+        },
+      },
+    };
+    const session = {};
+
+    const context = await connector.createContext({
+      event,
+      session,
+    });
+
+    expect(context).toBeDefined();
+    expect(context.accessToken).toBe('anyToken');
+  });
 });
 
 describe('#verifySignature', () => {
diff --git a/src/context/__tests__/MessengerContext.spec.js b/src/context/__tests__/MessengerContext.spec.js
index 843e881a..b143028f 100644
--- a/src/context/__tests__/MessengerContext.spec.js
+++ b/src/context/__tests__/MessengerContext.spec.js
@@ -68,12 +68,18 @@ const userSession = {
     id: 'fakeUserId',
   },
 };
-const setup = ({ session } = { session: userSession }) => {
+const setup = (
+  { session, customAccessToken } = {
+    session: userSession,
+    customAccessToken: undefined,
+  }
+) => {
   const client = createMockGraphAPIClient();
   const args = {
     client,
     event: new MessengerEvent(rawEvent),
     session,
+    customAccessToken,
   };
   const context = new MessengerContext(args);
   return {
@@ -720,7 +726,22 @@ describe('#typingOn', () => {
 
     await context.typingOn();
 
-    expect(client.typingOn).toBeCalledWith(session.user.id);
+    expect(client.typingOn).toBeCalledWith(session.user.id, {
+      access_token: undefined,
+    });
+  });
+
+  it('should use custom access token', async () => {
+    const { context, client, session } = setup({
+      session: userSession,
+      customAccessToken: 'anyToken',
+    });
+
+    await context.typingOn();
+
+    expect(client.typingOn).toBeCalledWith(session.user.id, {
+      access_token: 'anyToken',
+    });
   });
 
   it('should mark context as handled', async () => {
@@ -746,7 +767,22 @@ describe('#typingOff', () => {
 
     await context.typingOff();
 
-    expect(client.typingOff).toBeCalledWith(session.user.id);
+    expect(client.typingOff).toBeCalledWith(session.user.id, {
+      access_token: undefined,
+    });
+  });
+
+  it('should use custom access token', async () => {
+    const { context, client, session } = setup({
+      session: userSession,
+      customAccessToken: 'anyToken',
+    });
+
+    await context.typingOff();
+
+    expect(client.typingOff).toBeCalledWith(session.user.id, {
+      access_token: 'anyToken',
+    });
   });
 
   it('should mark context as handled', async () => {
@@ -773,7 +809,22 @@ describe('#markSeen', () => {
 
     await context.markSeen();
 
-    expect(client.markSeen).toBeCalledWith(session.user.id);
+    expect(client.markSeen).toBeCalledWith(session.user.id, {
+      access_token: undefined,
+    });
+  });
+
+  it('should use custom access token', async () => {
+    const { context, client, session } = setup({
+      session: userSession,
+      customAccessToken: 'anyToken',
+    });
+
+    await context.markSeen();
+
+    expect(client.markSeen).toBeCalledWith(session.user.id, {
+      access_token: 'anyToken',
+    });
   });
 
   it('should mark context as handled', async () => {
@@ -823,7 +874,28 @@ describe('#passThreadControl', () => {
     expect(client.passThreadControl).toBeCalledWith(
       session.user.id,
       263902037430900,
-      'metadata'
+      'metadata',
+      {
+        access_token: undefined,
+      }
+    );
+  });
+
+  it('should use custom access token', async () => {
+    const { context, client, session } = setup({
+      session: userSession,
+      customAccessToken: 'anyToken',
+    });
+
+    await context.passThreadControl(263902037430900, 'metadata');
+
+    expect(client.passThreadControl).toBeCalledWith(
+      session.user.id,
+      263902037430900,
+      'metadata',
+      {
+        access_token: 'anyToken',
+      }
     );
   });
 
@@ -843,7 +915,28 @@ describe('#passThreadControlToPageInbox', () => {
 
     await context.passThreadControlToPageInbox();
 
-    expect(client.passThreadControlToPageInbox).toBeCalledWith(session.user.id);
+    expect(client.passThreadControlToPageInbox).toBeCalledWith(
+      session.user.id,
+      {
+        access_token: undefined,
+      }
+    );
+  });
+
+  it('should use custom access token', async () => {
+    const { context, client, session } = setup({
+      session: userSession,
+      customAccessToken: 'anyToken',
+    });
+
+    await context.passThreadControlToPageInbox();
+
+    expect(client.passThreadControlToPageInbox).toBeCalledWith(
+      session.user.id,
+      {
+        access_token: 'anyToken',
+      }
+    );
   });
 
   it('should call warning if dont have session', async () => {
@@ -862,7 +955,9 @@ describe('#takeThreadControl', () => {
 
     await context.takeThreadControl();
 
-    expect(client.takeThreadControl).toBeCalledWith(session.user.id);
+    expect(client.takeThreadControl).toBeCalledWith(session.user.id, {
+      access_token: undefined,
+    });
   });
 
   it('should call warning if dont have session', async () => {
@@ -883,7 +978,23 @@ describe('#associateLabel', () => {
 
     expect(client.associateLabel).toBeCalledWith(
       session.user.id,
-      1712444532121303
+      1712444532121303,
+      { access_token: undefined }
+    );
+  });
+
+  it('should use custom access token', async () => {
+    const { context, client, session } = setup({
+      session: userSession,
+      customAccessToken: 'anyToken',
+    });
+
+    await context.associateLabel(1712444532121303);
+
+    expect(client.associateLabel).toBeCalledWith(
+      session.user.id,
+      1712444532121303,
+      { access_token: 'anyToken' }
     );
   });
 
@@ -905,7 +1016,23 @@ describe('#dissociateLabel', () => {
 
     expect(client.dissociateLabel).toBeCalledWith(
       session.user.id,
-      1712444532121303
+      1712444532121303,
+      { access_token: undefined }
+    );
+  });
+
+  it('should use custom access token', async () => {
+    const { context, client, session } = setup({
+      session: userSession,
+      customAccessToken: 'anyToken',
+    });
+
+    await context.dissociateLabel(1712444532121303);
+
+    expect(client.dissociateLabel).toBeCalledWith(
+      session.user.id,
+      1712444532121303,
+      { access_token: 'anyToken' }
     );
   });
 
@@ -946,7 +1073,43 @@ describe('#getAssociatedLabels', () => {
 
     await context.getAssociatedLabels();
 
-    expect(client.getAssociatedLabels).toBeCalledWith(session.user.id);
+    expect(client.getAssociatedLabels).toBeCalledWith(session.user.id, {
+      access_token: undefined,
+    });
+  });
+
+  it('should use custom access token', async () => {
+    const { context, client, session } = setup({
+      session: userSession,
+      customAccessToken: 'anyToken',
+    });
+
+    client.getAssociatedLabels.mockReturnValue({
+      data: [
+        {
+          name: 'myLabel',
+          id: '1001200005003',
+        },
+        {
+          name: 'myOtherLabel',
+          id: '1001200005002',
+        },
+      ],
+      paging: {
+        cursors: {
+          before:
+            'QVFIUmx1WTBpMGpJWXprYzVYaVhabW55dVpycko4U2xURGE5ODNtNFZAPal94a1hTUnNVMUtoMVVoTzlzSDktUkMtQkUzWEFLSXlMS3ZALYUw3TURLelZAPOGVR',
+          after:
+            'QVFIUmItNkpTbjVzakxFWGRydzdaVUFNNnNPaUl0SmwzVHN5ZAWZAEQ3lZANDAzTXFIM0NHbHdYSkQ5OG1GaEozdjkzRmxpUFhxTDl4ZAlBibnE4LWt1eGlTa3Bn',
+        },
+      },
+    });
+
+    await context.getAssociatedLabels();
+
+    expect(client.getAssociatedLabels).toBeCalledWith(session.user.id, {
+      access_token: 'anyToken',
+    });
   });
 
   it('should call warning if dont have session', async () => {