Microsoft Graphでユーザーのアバター画像を取得する

Microsoft Graphにはv1.0とbetaのエンドポイントがあります。
どちらにもユーザーのアバターを取得するAPIがあります。  

しかし、v1.0とbetaで取得できる場合とそうでない場合があるようです。

https://docs.microsoft.com/ja-jp/graph/api/profilephoto-get?view=graph-rest-1.0 https://docs.microsoft.com/ja-jp/graph/api/profilephoto-get?view=graph-rest-beta

v1.0とbetaで同じユーザーのアバターを取得してみる

アバターはサイズを指定して取得をできるので、必要なサイズのものを取得します。
データはバイナリで受け取るので、適当にBufferなどに変えてファイルに書き出すかData URI schemeに置き換えて、DBに格納したりすることができます。

ここではData URI schemeに変えるコードを載せておきます。

import fetch from 'node-fetch';

const v1Endpoint = 'https://graph.microsoft.com/v1.0';
const betaEndpoint = 'https://graph.microsoft.com/beta';

type Endpoint = "v1.0" | "beta";
type AvatarSize = "48x48" | "64x64" | "96x96";

type Avatar = {
  mimeType: string;
  url: string;
};

const userAvatar = (endpointType: Endpoint, size: AvatarSize) => async (userId: string): Promise<Avatar | undefined> => {
  const options = {
    method: "get",
    headers: {
      Authorization: `Bearer ${process.env.ACCESS_TOKEN}`
    }
  };

  const endpoint = endpointType === 'v1.0' ? v1Endpoint : betaEndpoint;
  const response = await fetch(`${endpoint}/users/${userId}/photos/${size}/$value`, options);

  if (!response.ok) {
    const failedBody = await response.json();
    console.log(failedBody);
    return;
  }

  const mimeType = response.headers.get('content-type') as string;

  const buffer = await response.buffer();
  const data = buffer.toString('base64');

  const url = `data:${mimeType};base64,${data}`;

  return {
    mimeType,
    url
  };
};

const v1Avatar = userAvatar("v1.0", "48x48");
const betaAvatar = userAvatar("beta", "48x48");

const main = async (userId: string) => {
  const v1 = await v1Avatar(userId);
  const beta = await betaAvatar(userId);
  console.log(v1);
  console.log(beta);
};

const userId = process.argv[2];

main(userId).then(() => {
  console.log('done');
}).catch(err => {
  console.log(err.stack);
});

実行するにはアクセストークンとユーザーIDを指定します。

ACCESS_TOKEN=[アクセストークン] \
node avatar.js [ユーザーID]

v1.0では一部のユーザーのアバターが取得できない場合がある

一部のユーザーではアバターを設定しているのにもかかわらず、アバターが取得できないユーザーがいました。
そのユーザーの場合はMailboxNotEnabledForRESTAPIがエラー本文に含まれていました。  

{
  error: {
    code: 'MailboxNotEnabledForRESTAPI',
    message: 'REST API is not yet supported for this mailbox.',
    innerError: {
      date: '2020-08-25T10:53:21',
      'request-id': xxxxxxxxxxxx
    }
  }
}

betaで問題が発生しないのは一部のAPIの制限がなくなったものと思われます。
OfficeなどのAPIがMicrosoft Graphに統合されているはずなのですが、まだ完全には終わっていないのかもしれないです。

Azure Bot ServiceのBot Framework SDKのkoa対応

最近はBot Framework v4を利用して、Botアプリケーションを作成しています。
Botアプリケーションを作成するには、SDKを利用するのですが、expressrestifyくらいしかWebアプリケーションのフレームワークが対応してないような気がします。

koaの場合

例えば、BotFrameworkAdapter.processActivity の要求にレスポンスオブジェクトを渡す必要があるのですが、 koacontext.response を渡すと status、send、end あたりのメソッドがない為に、ランタイムエラーが発生します。

これは、expressrestifyのレスポンスオブジェクトのインターフェースをBotFrameworkAdapterが期待しているからです。

また、koaの実装の場合、ミドルウェアを一通り処理した後に最終的な応答を返すので、サンプルでよくある実装をすると応答を正常に返せません。

// よくみるサンプルコード
// express/restifyの場合
app.post('/api/messages', (req, res) => {
  adapter.processActivity(req, res, async (context) => {
    await bot.run(context);
  });
});

Bot Emulatorを使用してデバッグすると、404を返した後に200を返したりします。

f:id:holyshared:20200802185533p:plain
Emulatorのログ

// 素直にkoaでサンプルのコードを実装した例
// routerはkoa-routerのインスタンス
router.post('/api/messages', async (ctx: RouterContext<{}, {}>) => {
  const { request: req, response: res } = ctx;
  adaptor.processActivity(req, res, async (turnContext: TurnContext): Promise<void> => {
    await bot.run(turnContext);
  });
});

この現象の理由は、processActivityをawaitしてない為、コールバック関数を抜けた後に、404を直ぐに返し、その後にBotのメッセージ応答が正常に終了し、200などの応答を返す為だと思います。

express/restifyの場合はawaitしなくてもレスポンスオブジェクトのendを実行したタイミングで応答が完了するので問題ないのですが、koaの場合はそうではなく、最終的な応答を返すのがフレームワーク側 なのでうまく行きません。

サンプルのコードとかだと、ただのコールバック関数で処理しているだけに見えたので若干困りました。

最終的な対応

原因がわかったので、適当なexpress/restifyのレスポンスオブジェクトを渡しつつ、processActivityをawaitして対応することにしました。

スマートな解決方法ではないですが、テキストメッセージをユーザーに送るくらいだったらこれで良さそうです。
また、WebResponseのインターフェース仕様を読む限り、end、send、statusだけ実装されてれば良いぽいのでこれであってそうではあります。

import Koa from 'koa';
import Router, { RouterContext } from 'koa-router';
import { BotFrameworkAdapter, TurnContext, WebResponse } from 'botbuilder';
import { Bot } from './bot';

const app = new Koa();
const router = new Router();

const adaptor = new BotFrameworkAdapter({
  appId: process.env.BOT_APP_CLIENT_ID,
  appPassword: process.env.BOT_APP_CLIENT_PASSWORD
});

const bot = new Bot();

const createWebResponse = (ctx: RouterContext<{}, {}>): WebResponse => ({
  status(code: number): void {
    ctx.status = code;
  },
  send(body: string): void {
    ctx.body = body;
  },
  end(): void {
  }
});

router.post('/api/messages', async (ctx: RouterContext<{}, {}>) => {
  const res = createWebResponse(ctx);
  await adaptor.processActivity(ctx.req, res, async (turnContext: TurnContext): Promise<void> => {
    await bot.run(turnContext);
  });
});

app.use(router.routes())
  .use(router.allowedMethods())
  .listen(3000);

HerokuのmLabからMongoDB Atlasへ

自分が運用している365shoes.styleはHerokuを使用しています。 そして、mLabアドオンを使用していました。

mLabアドオンはアナウンスがあったように利用できなくなります。
なので、何かしらの方法で移行する必要がありました。

移行先は一番移行が楽そうな、MongoDB Atlasを選びました。
MongoDB AtlasはmLabからの移行をサポートしています。

https://docs.mlab.com/how-to-migrate-to-atlas/#complete-migration-wizard-for-specific-deployment

ステージング環境の移行

ステージング環境のMongoDBをMongoDB Atlas側でリンクさせて、手順通りに進めました。
クラスタをタイプを指定し、マイグレーションを実行するとmLabのデータをまるっとMongoDB Atlasの環境に移してくれます。

データの内容を確認した後に、アプリケーションの環境変数を変えて、新しい方のMongoDBのデータベースをアクセスしているか確認します。
接続できるかはマイグレーションのステップで、接続確認をするステップがあるので、そこで接続できれば特に問題ないような気がします。

本番環境の移行

ステージング環境の移行が済んだので、次は本番環境なのですが、ちょっと手順が代わります。
MongoDB Atlasからは一つのDBしかリンクできないようで、一旦ステージングとのリンクを切って、本番用の方にリンクし直す必要があります。

「Disconnect from mLab」というのがあるので、それをクリックすればOKです。

f:id:holyshared:20200727005431p:plain
MongoDB Atlasの管理画面

そのあとは、ステージング環境と同じように移行するだけなのですが、下記の2つの問題がありました。

  1. ステージング環境と同じプロジェクトにしたら、ユーザー名がステージング環境のユーザー名になって、データが正常にインポートできなかった。
  2. 移行後のユーザーの権限がreadだけになってしまった。

1に関してはユーザー名が本番環境のやつと違うけど、大丈夫なのかなと思いつつ、データインポートの処理を実行したら、データがインポートされないので、試しに別の新しいプロジェクトを作成して、試したところ正常に完了しました。

2に関しては、マイグレーションのステップを途中までやっていて、最後まで完了させなかったのが問題のようです。 データ移行まで終わって、安心したのか最後らへんの1、2ステップを見落としていたような気がします。
途中でタブ閉じちゃったのがよくなかったと思います。

ステップとしては移行元の書き込みを停止させるためにユーザーの権限を読み取り権限だけにするようなプロセスになっているようです。
https://docs.mlab.com/how-to-migrate-to-atlas/#importing-from-source-to-target

移行作業のまとめ

ウィザード形式で移行が進むので手順は楽でとてもよかったです。
その代わり、現状どういう状態なのかを確認して、各ステップでどうならなければならないといけないかを意識して作業しないと見落としがでそうです。

通常では移行作業自体は、手順を整理してこういう細かいことをやるのですが、あらかじめ手順が誰かに用意されている場合、意識することは少ないので気をつけたいです。

画像の変換にimgixを使う

アップロードした画像を変換するのに最近は、SaaSを使用しています。
普段はcloudinaryを使用していたのですが、imgixを試してみたら導入が簡単でした。

設定そのものは、imgixの設定画面でAWSのS3のバケットを指定して、アクセスキー、シークレットキーを指定する感じです。
後はimgixのライブラリを利用して、URLのパスを生成だけです。

import ImgixClient from 'imgix-core-js';

const client = new ImgixClient({
  domain: "example.imgix.net",
});

const key = "/wxyz_user_assets/original.jpg"; // S3のオブジェクトキー
const url = client.buildURL(key, { w: 400, h: 300 });

console.log("resize image: %s", url);

URLのセキュリティトークン

管理画面で Secure URLs を有効にすると、URLにセキュリティ用のトークンを追加できます。
クライアントのオプションで、トークンを設定するとURL生成時に、トークン + パス + クエリパラメータ をMD5でハッシュ化したパラメータをURLのクエリに追加してくれます。

このトークンがない状態で画像のURLにアクセスすると、403 のステータスコードを返してくれます。
この仕組みは特定のユーザーにしか閲覧を許可したくない場合に有効です。

URLが外部に漏れてしまっても、トークンを変えるだけで、アクセスできなくすることができます。
また、第三者にアプリケーションで使用していないサイズの画像をURLを変えることで生成されることも防げます。

可能な限り有効にしておいた方がいいと思います。

import ImgixClient from 'imgix-core-js';

const client = new ImgixClient({
  domain: "example.imgix.net",
  secureURLToken: process.env.IMGIX_SECURE_TOKEN
});

const key = "/wxyz_user_assets/original.jpg"; // S3のオブジェクトキー
const url = client.buildURL(key, { w: 400, h: 300 });

console.log("resize image: %s", url);

画像の読み込み

クライアントで画像のexifを読み出すのに普段はblueimp-load-imageを使用しています。
画像をアップロードをするフォームの投稿日のデフォルト値を指定したりする時や、写真をトリミングするコンポーネントなどで利用しています。  

自分がメンテナンスしているアプリケーションで古いバージョン2系を使用していたので、新しいバージョンの5系にアップグレードしました。

const options = {
  maxWidth: 600,
  orientation: true,
  meta: true,
  canvas: true // 1. オプションの追加
};
const photoEnableOrientation = document.getElementById('photoEnableOrientation');
photoEnableOrientation.addEventListener('change', (evt) => {
  loadImage(evt.target.files[0], options).then((result, meta) => {

    const exif = result.exif.get("Exif"); // 2. Exif IFDの読み出し
    const dateTimeOriginal = exif.get("DateTimeOriginal");
    console.log(dateTimeOriginal);

    document.body.appendChild(result.image);
  }).catch(err => {
    console.log(err);
  });
});

主な変更点は下記の通りでした。

  1. オプションの修正

    古いバージョンだと、コールバック関数の引数がHTMLCanvasElementだったのが、オプションでcanvas: trueを指定しないと、デフォルトではHTMLImageElementになる。

    また、Promiseだと結果オブジェクトになっていて、imageプロパティで参照できるようだ。

  2. exifオブジェクトのインターフェース変更の修正

    読み込み結果のオブジェクトにexifオブジェクトがあったが、参照キーが変わったようで撮影日時が取れなくなっていた。
    DateTimeOriginal指定で参照できていたが、Exifキーを指定して、Exif IFD の結果に対して、DateTimeOriginalを指定する必要があった。

修正自体はそんなに大したことなかったです。

追加で物をサマリーポケットに預けた

あんまり着ていない物とか、使ってない物をまたサマリーポケットに預けてみました。
今回もアパレルのボックスを利用してしました。

物としては下記の物をパッケージングして送りました。

  • LEVI'SのGジャン
  • LEVI'Sのギャリソンベルト x 3
  • ナイロン製のアウター
  • サイドゴアブーツ
  • 6インチブーツ
  • コットンのハット
  • ベロアのナップザック

箱の高さはGジャン、ナイロン製のアウターでちょうどいい感じになりました。
頑張れば、軽めのアウター3着くらいは行けそうです。

サマリーポケット使ってみた

2ヶ月前くらいに、靴の整理にサマリーポケット使ってみました。
箱はアパレルの横長を頼んでみました。

f:id:holyshared:20200408190211j:plain
梱包している箱

送った内容は、最終的に次のようになりました。

  • 短靴4足
  • 羽織りようのアウター 1着
  • パーカー 1着

サイズ間としては短靴が最大で6足位入りそうな感じでした。

使用感

梱包してからアプリで集荷するのが楽でしたね。
伝票のこととか考えなくて済むので...。

とりあえず捨てようか迷っているなら、サマリーポケットで預けてしまうのがいいのかもしれません。
服とかも沢山あるので、重宝しそうです。