LINEボットでtodoist連携

todoistにやることリストを追加したり、今日のやることリストを取得してくれるLINEボットを作成します。
前回の投稿「 [AlexaとTodoistでやることリスト・お買い物リスト](https://qiita.com/poruruba/items/0af9659808878ad822a3) 」の応用です。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/261826/05c1833b-052e-9705-1871-3768aa2c4482.png)

今回作成するのは、Node.jsで作成するLINEボットサーバです。
図の点線矢印は、最初の設定のときのみで、それが終わってしまえば、あとは、何回でもLINEアプリからtodoistの操作ができるようになります。

とりあえず、LINEアプリからは以下ができるようにします。これ以外は、直接todoistアプリから操作してください。

・タスクの新規登録、および担当者のアサイン、期限の設定
・今日、明日、明後日以降、期限切れのタスクの一覧を取得
・タスクの詳細を表示
・タスクの完了を設定

毎度の通り、ソースコード一式をGitHubに上げておきました。

poruruba/line-todoist
　https://github.com/poruruba/line_todoist

(修正) 2020/12/08
todoist認証において、stateに推測されにくい乱数を指定するようにしました。コメントありがとうございました。

#LINEボットの設定

LINEデベロッパーコンソールから登録します。

LINEデベロッパーコンソール
　https://developers.line.biz/console/

プロバイダを選択または新規作成したのち、チャネル設定の新規チャネルを選択します。
 
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/261826/ef92f1c4-2fdc-153d-c5d8-439c6c9a17d8.png)

チャネルの種類は、Messaging APIです。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/261826/7aa5cc8c-8815-4a2b-3078-5e5e9de7d42a.png)

適当に入力し、利用規約に同意して、「作成」ボタンを押下します。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/261826/f1852867-afab-6607-ff3d-f8c696a43b05.png)

作成後に表示されるチャネル基本設定にあるチャネルシークレットとMessaging API設定にあるチャネルアクセストークン(長期)の発行ボタンで生成されるチャネルアクセストークン(長期)を覚えておきます。

また、Messaging API設定の応答メッセージは無効に変更しておきます。グループ・複数人チャットへの参加を許可するも有効にしておきます。

# Node.jsの立ち上げ

GitHubからZIPダウンロードしてください。
　https://github.com/poruruba/line_todoist

```
> unzip line_todoist-master.zip
> cd line_todoist
> mkdir data
> mkdir data\line-todoist
> mkdir cert
> npm install
> node app.js
```

HTTPSである必要があるため、certフォルダにSSL証明書を格納します。ファイル名は、app.jpを参照してください。

まずは、LINEボットとして応答できるようにするためには、最低限以下の記載があればよいです。

```js:api/controllers/line-todoist/index.js
'use strict';

const config = {
  channelAccessToken: process.env.LINE_CHANNEL_ACCESS_TOKEN || '【LINEチャネルアクセストークン(長期)】',
  channelSecret: process.env.LINE_CHANNEL_SECRET || '【LINEチャネルシークレット】',
};

const HELPER_BASE = process.env.HELPER_BASE || '../../helpers/';

const line = require('@line/bot-sdk');
const LineUtils = require(HELPER_BASE + 'line-utils');
const app = new LineUtils(line, config);

app.message(async (event, client) =>{
  console.log(event);
  var text = event.message.text

  const echo = { type: 'text', text: text + 'だよ' };
  return client.replyMessage(event.replyToken, echo);
});

exports.fulfillment = app.lambda();
```

Swaggerファイルには以下を最低限記載します。

```yaml:api/controllers/line-todoist/swagger.yaml
swagger: '2.0'
info:
  version: 'first version'
  title: Lambda Laboratory Server
  
paths:
  /line-todoist:
    post:
      x-handler: fulfillment
      parameters:
        - in: body
          name: body
          schema:
            $ref: "#/definitions/CommonRequest"
      responses:
        200:
          description: Success
          schema:
            $ref: "#/definitions/CommonResponse"
```

【LINEチャネルアクセストークン(長期)】と【LINEチャネルシークレット】のところに、先ほど覚えておいた値を付記しておきます。

細かな処理は、api/helpers/line-utils.js で処理させています。以下のnpmモジュールを使っています。

line/line-bot-sdk-nodejs
　https://github.com/line/line-bot-sdk-nodejs

#LINEボットのWebhook設定

LINEアプリでの発話をnode.jsサーバに通知してもらうためにWebhookの設定をします。
LINEデベロッパーの、Messagin API設定に、Webhook設定があります。
ここに、立ち上げたNode.jsサーバのURLを指定します。以下のような感じです。

　https://【Node.jsサーバのホスト名】/line-todoist

これで、Webhookの利用をOKにして、検証ボタンを押下すると、「成功」と表示されるかと思います。
ついでに、同じページに表示されているQRコードから、LINEアプリでこのLINEボットを友達として登録しておきましょう。

#LINEとtodoistの連携

LINEとtodoistの連携は、このLINEボットが担います。
連携のための設定情報は、Node.jsサーバの ```data/line-todoist/``` フォルダにJSONファイルとして保存します。
目標とするConfigファイル名と中身は以下の通りです。

```
ファイル名：user-【LINEのユーザID】 またはgroup-【LINEのグループID】
ファイルの中身：
{
  "token": {
    "access_token": "【todoistのAPIトークン】",
    "token_type": "Bearer"
  },
  "apikey": "【本ファイルをアクセスするためのAPIキー】",
  "project_id": 【todoistの対象プロジェクトID】
}
```

まず、ファイル名がLINEのユーザIDまたはグループIDとなっています。
LINEボットをお友達にしたのち直接LINEボットと会話する場合と、LINEのグループにLINEボットを招待して会話する場合の両方があるためです。対象となるLINEのIDは、前者がユーザID、後者がグループIDとなります。

それらのLINEのIDとtodoistをどうやって結びつけるかというと、いったんLINEボットからLINEのIDとともに、これから立ち上げるWebページに飛ばし、そこでユーザによりtodoistと認証した結果をLINEボットに戻すことで、行っています。

#todoistのアプリ登録

todoistで認証できるようにアプリ登録します。

Todoist App Management
　https://developer.todoist.com/appconsole.html

「Create a new app」ボタンを押下します。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/261826/9a258980-335b-ca17-01af-a68fa00988d9.png)

大事なのは、OAuth redirect URLの部分です。
さきほど立ち上げたNode.jsサーバのURLを指定します。

　https://【Node.jsサーバのホスト名】/admin/index.html

public/admin/フォルダに、静的ページを用意しておきました。
todoist認証完了後、todoistの認可コード付きでこのページに戻るようになります。

Client IDとClient secretも使うので覚えておきます。

流れはこんな感じです。

①LINEアプリからtodoist認証ページへ切り替える
以下の部分です。
todoistのAPIトークンがすでにある場合は、直接静的ページ/admin/index.htmlに飛びますが、ない場合は、todoistの認証URLを返しています。

```js:api/controllers/line-todoist/index.js
      var message;
      if( !conf.token ){
        // 新規登録
        conf.state = sourceId + '_' + Buffer.from(crypto.randomBytes(12)).toString('hex');
        await writeConfigFile(sourceId, conf);
        message = {
          type: "template",
          altText: "設定",
          template: {
            type: "buttons",
            text: "まだTodoistが設定されていません。",
            actions: [
              {
                type: "uri",
                label: "設定ページ",
                uri: "https://todoist.com/oauth/authorize?client_id=" + TODOIST_CLIENT_ID + "&scope=data:read_write&state=" + conf.state + "&openExternalBrowser=1"
              }
            ]
          }
        }
      }else{
        // 登録済み
        message = {
          type: "template",
          altText: "設定",
          template: {
            type: "buttons",
            text: "すでにTodoistが設定されています。",
            actions: [
              {
                type: "uri",
                label: "設定ページ",
                uri: ADMIN_PAGE_URL + "?openExternalBrowser=1"
              }
            ]
          }
        }
      }
      return client.replyMessage(event.replyToken, message);
```
上記処理は、LINEチャットから以下を入力することで起動します。

```
/todoist config
```

ちなみに、「/(スラッシュ)」で続くチャットのみ、今回作成するLINEボットで扱うようにしています。

②認可コードをNode.jsサーバにわたす。

todoistのユーザ認証が完了すると、認可コードとともに、静的ページに戻ってきます。
静的ページでは、LINEのID、todoistの認可コード、パスワードとして使うapikeyをNode.jsに渡します。以下の部分です。

```js:public/admin/js/start.js
        if( searchs.code ){
            // todoist認証後の認可コード
            var state = searchs.state;
            var code = searchs.code;
            history.replaceState(null, null, '.');
            var apikey = prompt("API Keyを指定してください。");
            if( !apikey )
                return;

            // todoistのトークン設定
            var param = {
                code: code,
                state: state
            };
            do_post_apikey(base_url + '/line-todoist-callback', param, apikey)
            .then(json =>{
                this.apikey = apikey;
                this.sourceId = json.sourceId;
                Cookies.set("line_apikey", this.apikey, { expires: EXPIRES });
                Cookies.set("line_sourceId", this.sourceId, { expires: EXPIRES });

                this.get_config();
            });
        }else{
```

③認可コードからAPIトークンを取得する

まずは、ブラウザから、POST呼び出しを受け付けるために、HTTP POSTエンドポイントを作成します。
以下の部分です。

```js:api/controllers/line-todoist/index.js
exports.handler = async (event, context, callback) => {
  var body = JSON.parse(event.body);

if( event.path == '/line-todoist-callback' ){
・・・
  }
};
```

また、swagger.xmlにエンドポイントを定義します。

```yaml:api/controllers/line-todoist/swagger.yaml
paths:
・・・
  /line-todoist-callback:
    post:
      security:
        - apikeyAuth: []
      parameters:
        - in: body
          name: body
          schema:
            $ref: "#/definitions/CommonRequest"
      responses:
        200:
          description: Success
          schema:
            $ref: "#/definitions/CommonResponse"
```

で、認可コードを受け取って、todoistのWebAPIを使ってAPIトークンの取得する処理は以下の部分です。

```js:api/controllers/line-todoist/index.js
const TODOIST_CLIENT_ID = process.env.TODOIST_CLIENT_ID || "【todoistのClient ID】";
const TODOIST_CLIENT_SECRET = process.env.TODOIST_CLIENT_SECRET || "【todoistのClient secret】";

・・・
    // トークン取得処理
    var apikey = event.requestContext.apikeyAuth.apikey;
    var params = body.state.split('_', 2)
    var sourceId = params[0];

    var conf = await readConfigFile(sourceId);
    if( !conf )
      throw "invalid state";

    if( conf.state != body.state )
      throw "invalid state";

    // トークン取得呼び出し
    var param = {
      client_id: TODOIST_CLIENT_ID,
      client_secret: TODOIST_CLIENT_SECRET,
      code: body.code
    };
    var json = await do_post("https://todoist.com/oauth/access_token", param );

    // sourceIdのConfigファイル更新
    if( !conf.apikey ){
      conf.apikey = apikey;
    }else
    if( conf.apikey != apikey ){
        throw 'invalid apikey';
    }

    conf.token = json;
    await writeConfigFile(sourceId, conf);

    return new Response({ sourceId: sourceId });
```

④LINEボットがタスク登録するtodoistプロジェクトの選択

静的ページには、todoistに作成してあるプロジェクト一覧が表示されているかと思います。
そのうちから、LINEボットが登録する先のプロジェクトをセレクトボックスから選択して、「Select Project」ボタンを押下します。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/261826/bfa95b8b-2361-92fa-3afd-83a3d20c4e0e.png)

以下の部分が呼ばれて、project_idがConfigファイルに設定されます。

```js:api/controllers/line-todoist/index.js
  if( event.path == '/line-todoist-set-config' ){
    // project_id設定要求
    var apikey = event.requestContext.apikeyAuth.apikey;
    var sourceId = body.sourceId;

    // sourceIdのConfigファイル更新
    var conf = await readConfigFile(sourceId);
    if( conf.apikey != apikey )
      throw 'apikey mismatch';

    conf.project_id = body.project_id;
    await writeConfigFile(sourceId, conf);

    return new Response({});
```

これで、Configファイルに必要な値が埋まりました。

#タスク(やることリスト)の登録

LINEチャットで以下のように入力します。

```
/todo add タスク名
```

以下の部分が呼ばれます。

```js:api/controllers/line-todoist/index.js
const Todoist = require('todoist').v8;

・・・

  if( command.cmd == 'todo' && command.ope == 'add' ){
    // タスクの追加

    // sourceIdからConfigファイル取得
    var conf = await readConfigFile(sourceId);
    if( !conf || !conf.token )
      return client.replyMessage(event.replyToken, { type: 'text', text: 'todoistが設定されていません。' });

    // 指定プロジェクトにタスクの追加
    const todoist = Todoist(conf.token.access_token);
    const newItem = await todoist.items.add({ content: command.param, project_id: conf.project_id });

    var item_id = newItem.id;
    return client.replyMessage(event.replyToken, make_item_suggestion(item_id, "やることリストに追加しました。"));
```

Configファイルに保存しておいたtodoistのAPIトークンを使ってタスクを登録しています。
npm node-todoistを利用していますが、使い方は以下を参照してください。

romgrk/node-todoist
　https://github.com/romgrk/node-todoist

「やることリストに追加しました」と返答がかえってきました。
合わせて、続けて設定できるように「アサイン設定」「期限(日付)」がサジェスチョンされてます。
「アサイン設定」は後述の処理を動かすためのポストバック、「期限(日付)」はユーザに日付を選択してもらうための日時選択アクションをサジェスチョンに設定しています。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/261826/666e9e39-ba6e-3e5b-01fe-bb27c52550d2.png)

アサイン設定のサジェスチョンを選択すると以下の処理が走ります。

```js:api/controllers/line-todoist/index.js
  if( data[0] == 'todo_asign_select'){
    // アサイン選択要求(todo_asign_select,item_id)
    var item_id = parseInt(data[1]);

    // todoist.sync()呼び出し
    const todoist = Todoist(conf.token.access_token);

    // タスクの検索
    await todoist.sync();
    var task = todoist.items.get().find(item => item.id == item_id);

    const collaborators = todoist.sharing.collaborators();
    const collaboratorStates = todoist.state.collaborator_states;
    // 指定プロジェクトの共有ユーザリストの抽出
    var members = collaboratorStates.filter(item => item.project_id == conf.project_id );

    if( members.length > 0){
      var message = {
        type: 'text',
        text: "アサインする人を選択してください。",
        quickReply: {
          items: []
        }
      };
      members.forEach(member =>{
        const user = collaborators.find(item => member.user_id == item.id);
        const param = {
          type: "action",
          action: {
            type: "postback",
            label: user.full_name,
            data: "todo_asign," + item_id + ',' + user.id,
            displayText: user.full_name
          }
        }
        message.quickReply.items.push(param);
      });

      return client.replyMessage(event.replyToken, message);
    }else{
      return client.replyMessage(event.replyToken, make_item_suggestion(item_id, "アサインできる人がいません。", (task.due) ? task.due.date : undefined));
    }
```

対象プロジェクトの共有者をリストアップし、アサイン候補者としてサジェスチョンを表示してます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/261826/58a8301d-e71e-ad58-8455-e91b5c61a2a9.png)

期限(日付)のサジェスチョンを選択した場合、以下の処理が動きます。ユーザが日付を選択した後に呼ばれます。

```js:api/controllers/line-todoist/index.js
  if(data[0] == 'todo_duedate'){
    // 期限(日付)設定要求(todo_duedate,item_id)
    var item_id = parseInt(data[1]);
    const todoist = Todoist(conf.token.access_token);
    todoist.items.update({ id: item_id, due: { date: event.postback.params.date, lang: 'ja' }});

    return client.replyMessage(event.replyToken, make_item_suggestion(item_id, "変更しました。", event.postback.params.date));
```

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/261826/6c4eeaf0-6404-45aa-42a9-c97f797011cb.png)

さっそく、todoistアプリから覗いてみましょう。
上記のタスクが登録されているのではないでしょうか。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/261826/43ff764b-31b4-7a68-1b74-33b833d13341.png)

#今日のタスクの一覧取得

タスク一覧を取得します。
操作しやすいように、LINEアプリにチャットとして「/」を入力することで、「今日」「明日」「明後日以降」「期限切れ」「いつか」を選択できるサジェスチョンを表示し、それを選択してもらうと使いやすいかと思いました。

「/」がチャットされたときには以下が動きます。

```js:api/controllers/line-todoist/index.js
  if( command.cmd == 'default' ){
    return client.replyMessage(event.replyToken, make_tasksearch_suggestion());
```

そして、ユーザによりサジェスチョンを選択されると、以下が動きます。

```js:api/controllers/line-todoist/index.js
  if( data[0] == 'todo_today' || data[0] == 'todo_tommorow' || data[0] == 'todo_other' || data[0] == 'todo_someday' || data[0] == 'todo_expire'){
    // タスクリスト取得要求(todo_xxx)

    // todoist.sync()呼び出し
    const todoist = Todoist(conf.token.access_token);
    await todoist.sync();
    const collaborators = todoist.sharing.collaborators();
    var items = todoist.items.get();
    if( conf.project_id )
      items = items.filter(item => item.project_id == conf.project_id); // 対象プロジェクトにフィルタリング

    // 境界時間の算出
    var today = new Date();
    today.setHours(0, 0, 0, 0);
    var todayTime = today.getTime(); // 今日の開始
    var tomorrow = new Date();
    tomorrow.setHours(0, 0, 0, 0);
    tomorrow.setDate(tomorrow.getDate() + 1);
    var tomorrowTime = tomorrow.getTime();
    var aftertomorrow = new Date(); // 明日の開始
    aftertomorrow.setHours(0, 0, 0, 0);
    aftertomorrow.setDate(aftertomorrow.getDate() + 2);
    var aftertomorrowTime = aftertomorrow.getTime(); // 明後日の開始

    var target_list;
    var title;
    if( data[0] == 'todo_today' ){
      // 期限が今日の始めから明日の始め
      title = "今日";
      target_list = items.filter( item => item.due && Date.parse(item.due.date) >= todayTime && Date.parse(item.due.date) < tomorrowTime );
    }else
    if( data[0] == 'todo_tommorow' ){
      // 期限が明日の初めから明後日の始め
      title = "明日";
      target_list = items.filter( item => item.due && Date.parse(item.due.date) >= tomorrowTime && Date.parse(item.due.date) < aftertomorrowTime );
    }else
    if( data[0] == 'todo_other' ){
      // 期限が明後日の始め以降
      title = "明後日以降";
      target_list = items.filter( item => item.due && Date.parse(item.due.date) >= aftertomorrowTime );
    }else
    if( data[0] == 'todo_expire' ){
      // 期限が今日の始め以前
      title = "期限切れ";
      target_list = items.filter( item => item.due && Date.parse(item.due.date) < todayTime );
    }else{
      // 期限が未設定
      title = 'いつか';
      target_list = items.filter( item => !item.due );
    }
    console.log(target_list);

    var message = make_todolist_message(title + "のやることリスト", target_list, collaborators);
    return client.replyMessage(event.replyToken, message);
```

例えば、期限を明日に設定していると、明日を選択すると以下のように、リストの中の1つとして表示されています。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/261826/09949866-3f36-ac77-3126-c4f45d855b5b.png)

サジェスチョンに、項目番号をいれておきました。それを選択すると、そのタスクの詳細が表示されるようにしました。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/261826/ddbf0058-5f2b-eb80-7eb6-c6f97b07e116.png)

以下の部分です。

```js:api/controllers/line-todoist/index.js
  if( data[0] == 'todo_detail'){
    // タスクの詳細表示要求(todo_detail,item_id)
    var item_id = parseInt(data[1]);

    // todoist.sync()呼び出し
    const todoist = Todoist(conf.token.access_token);
    await todoist.sync();
    var items = todoist.items.get();
    const collaborators = todoist.sharing.collaborators();
    var notes = todoist.notes.get();

    // タスクの検索
    var task = items.find(item => item.id == item_id );

    var message = make_tododetail_message(task, collaborators, notes);
    return client.replyMessage(event.replyToken, message);
```

もし、コメントを残しているようであれば、それも表示するようにしています。あと、ラベルも表示するようにしています。

「完了する」というサジェスチョンも用意しておきました。言葉どおり、タスクを完了状態にします。
以下の処理が動きます。

```js:api/controllers/line-todoist/index.js
  if( data[0] == 'todo_complete'){
    // タスク完了要求(todo_complete,item_id)
    var item_id = parseInt(data[1]);
    const todoist = Todoist(conf.token.access_token);
    todoist.items.complete({ id: item_id} );

    return client.replyMessage(event.replyToken, { type: 'text', text: "完了にしました" });
```

#終わりに

ちょっと説明が少なく、わかりにくかったかもしれません。
機能拡張の機会があれば、その時にもう少し補足しようかな。。。

以下も参考にしてください。

　[AlexaとTodoistでやることリスト・お買い物リスト](https://qiita.com/poruruba/items/0af9659808878ad822a3)
　[Dialogflowと連携してLINE Botを作る](https://qiita.com/poruruba/items/09563e251adef1e1e8af)
　[LINE Beaconを自宅に住まわせる](https://qiita.com/poruruba/items/a28476ad53b2232c789d)

以上