Backlog に課題の登録や削除ができる LINE Bot を作ってみた

この記事は公開されてから1年以上経過しています。情報が古い可能性がありますので、ご注意ください。

はじめに

テントの中から失礼します、IoT 事業部のてんとタカハシです!

下記の記事を読んだことがきっかけで、我が家では家庭のタスクを Backlog で管理しています。

しばらく運用してみて、LINE から課題の登録ができたら便利かな〜?と思い、サクッと作ってみました。誤った課題を登録してしまった時のために、削除機能もおまけで付けています。

デモ

LINE でメッセージを送信すると、それが件名となる課題が登録されます。

「確認する」をタップすると、Backlog の画面に遷移します。スマホに Backlog のアプリを入れている場合は、自動でアプリが起動します。

続いて、「削除する」をタップすると、登録した課題が削除されます。

削除した後に「確認する」をタップすると、画面の遷移はされますが、対象の課題が存在しないため「課題が見つかりません」と表示されます。

こんな感じで非常にシンプルな Bot ですので、Backlog の使い方が分からない人だったり、出先でふと思いついたタスクを瞬時に登録したいときなどに使えるかなと思います。

構成

構成はめちゃくちゃシンプルです。LINE の Webhook に Lambda Function URLs を設定して、起動した Lambda から Backlog API を叩くだけです。

Lambda Function URLs を活用することで、前段に API Gateway を構える必要が無くなるので、とても使い勝手が良いです。

環境

各リソースは AWS CDK で実装しました。言語は TypeScript です。

% sw_vers
ProductName:    macOS
ProductVersion: 12.5.1
BuildVersion:   21G83

% aws --version
aws-cli/2.7.9 Python/3.9.11 Darwin/21.6.0 exe/x86_64 prompt/off

% cdk --version
2.35.0 (build 5c23578)

% npm --version
8.5.0

% yarn --version
1.22.18

% docker --version
Docker version 20.10.16, build aa7e414

準備

Line Bot を開発するために必要な準備については下記が参考になります。

また、Backlog API を使用するために必要な API キーの取得については下記が参考になります。

それぞれで取得した認証情報については、ソースコード上に埋め込まないため、環境変数として登録しておきます。

% export LINE_CHANNEL_ACCESS_TOKEN="xxx"
% export LINE_CHANNEL_SECRET="yyy"
% export BACKLOG_API_KEY="zzz"

ライブラリの追加

line-bot-sdk は必須になります。また、ヌーラボさんが用意している backlog-js を追加して、より簡単に Backlog API を叩けるようにします。こちらのライブラリは内部で Fetch や FormData が使用されているため、ブラウザ以外の環境でも実行できるように代わりとなるライブラリも追加します。

% yarn add @line/bot-sdk
% yarn add backlog-js
% yarn add isomorphic-fetch
% yarn add isomorphic-form-data

実装

CDK - cdk.json

cdk.json に Backlog に関する情報を入力します。

cdk.json

{
  "app": "npx ts-node --prefer-ts-exts bin/backlog-line-bot.ts",
  ...
  "context": {
    ...
    "backlog": {
      "hostName": "<HOST_NAME>",
      "projectId": <PROJECT_ID>,
      "priorityId": 3,
      "issueTypeId": <ISSUE_TYPE_ID>
    }
  }
}

hostName

お使いの環境に合わせて Backlog のホスト名に置き換えてください。

projectId

課題の追加先とするプロジェクトの ID に置き換えてください。プロジェクト ID は「プロジェクト設定」ページの URL から確認することができます。

priorityId

追加する課題の優先度を表す ID に置き換えてください。優先度の ID はこちらから確認することができます。上記では優先度「中」を表す「3」としています。

issueTypeId

追加する課題の種別を表す ID に置き換えてください。こちらは「プロジェクト設定」ページにて種別を選択してから、

表示されるページの URL から確認することができます。

CDK - bin

bin/ 配下のコードは下記の通りです。

bin/backlog-line-bot.ts

#!/usr/bin/env node
import 'source-map-support/register';
import * as cdk from 'aws-cdk-lib';
import { BacklogLineBotStack } from '../lib/backlog-line-bot-stack';

const app = new cdk.App();

const backlog = app.node.tryGetContext('backlog');
backlog.apiKey = app.node.tryGetContext('backlogApiKey');

const line = {
  channelAccessToken: app.node.tryGetContext('lineAccessToken'),
  channelSecret: app.node.tryGetContext('lineSecret'),
};

new BacklogLineBotStack(app, 'backlog-line-bot-stack', {
  backlog,
  line,
});

CDK - lib

lib/ 配下のコードは下記の通りです。Lambda のみ定義するだけの内容になります。

lib/backlog-line-bot-stack.ts

import * as path from 'path';

import { Stack, StackProps, Duration } from 'aws-cdk-lib';
import { Construct } from 'constructs';
import * as lambda from 'aws-cdk-lib/aws-lambda';
import * as lambdaNodejs from 'aws-cdk-lib/aws-lambda-nodejs';

export interface BacklogLineBotStackProps extends StackProps {
  backlog: {
    hostName: string;
    projectId: number;
    priorityId: number;
    issueTypeId: number;
    apiKey: string;
  };
  line: {
    channelAccessToken: string;
    channelSecret: string;
  };
}

export class BacklogLineBotStack extends Stack {
  constructor(scope: Construct, id: string, props: BacklogLineBotStackProps) {
    super(scope, id, props);

    const { backlog, line } = props;

    const lambdaFunction = new lambdaNodejs.NodejsFunction(
      this,
      'BacklogLineBotFunction',
      {
        functionName: 'backlog-line-bot-function',
        runtime: lambda.Runtime.NODEJS_16_X,
        entry: path.join(__dirname, '../src/lambda/index.ts'),
        handler: 'handler',
        timeout: Duration.seconds(60),
        environment: {
          BACKLOG_HOST_NAME: backlog.hostName,
          BACKLOG_PROJECT_ID: backlog.projectId.toString(),
          BACKLOG_PRIORITYID: backlog.priorityId.toString(),
          BACKLOG_ISSUE_TYPE_ID: backlog.issueTypeId.toString(),
          BACKLOG_API_KEY: backlog.apiKey,
          LINE_CHANNEL_ACCESS_TOKEN: line.channelAccessToken,
          LINE_CHANNEL_SECRET: line.channelSecret,
        },
      }
    );
    lambdaFunction.addFunctionUrl({
      authType: lambda.FunctionUrlAuthType.NONE,
    });
  }
}

Lambda

Lambda のソースコードは下記の通りです。

src/lambda/index.ts

import { APIGatewayEvent } from 'aws-lambda';
import { Backlog } from 'backlog-js';
import 'isomorphic-fetch';
import 'isomorphic-form-data';
import * as Line from '@line/bot-sdk';

const BACKLOG_HOST_NAME = process.env.BACKLOG_HOST_NAME!;
const BACKLOG_PROJECT_ID = Number(process.env.BACKLOG_PROJECT_ID!);
const BACKLOG_PRIORITYID = Number(process.env.BACKLOG_PRIORITYID!);
const BACKLOG_ISSUE_TYPE_ID = Number(process.env.BACKLOG_ISSUE_TYPE_ID!);
const BACKLOG_API_KEY = process.env.BACKLOG_API_KEY!;
const LINE_CHANNEL_ACCESS_TOKEN = process.env.LINE_CHANNEL_ACCESS_TOKEN!;
const LINE_CHANNEL_SECRET = process.env.LINE_CHANNEL_SECRET!;

const config: Line.ClientConfig = {
  channelAccessToken: LINE_CHANNEL_ACCESS_TOKEN,
  channelSecret: LINE_CHANNEL_SECRET,
};

const lineClient = new Line.Client(config);
const backlog = new Backlog({
  host: BACKLOG_HOST_NAME,
  apiKey: BACKLOG_API_KEY,
});

const handleEvent = async (event: Line.WebhookEvent) => {
  if (event.type === 'message' && event.message.type === 'text') {
    await addTaskEvent(event);
  } else if (event.type === 'postback') {
    await deleteTaskEvent(event);
  }
};

const addTaskEvent = async (event: Line.MessageEvent) => {
  const message = event.message as Line.TextEventMessage;
  const { issueKey } = await backlog.postIssue({
    projectId: BACKLOG_PROJECT_ID,
    summary: message.text,
    priorityId: BACKLOG_PRIORITYID,
    issueTypeId: BACKLOG_ISSUE_TYPE_ID,
  });

  const url = `https://${BACKLOG_HOST_NAME}/view/${issueKey}`;
  return await lineClient.replyMessage(event.replyToken, {
    type: 'template',
    altText: `課題「${issueKey}」を登録しました`,
    template: {
      type: 'confirm',
      text: `課題「${issueKey}」を登録しました`,
      actions: [
        {
          label: '確認する',
          type: 'uri',
          uri: url,
        },
        {
          label: '削除する',
          type: 'postback',
          data: issueKey,
          displayText: '削除する',
        },
      ],
    },
  });
};

const deleteTaskEvent = async (event: Line.PostbackEvent) => {
  const issueKey = event.postback.data;
  let replyMessage = `課題「${issueKey}」を削除しました`;
  try {
    await backlog.deleteIssuesCount(issueKey);
  } catch (e: any) {
    if (e._status === 404) {
      replyMessage = `課題「${issueKey}」は既に削除済みです`;
    } else {
      replyMessage = `課題「${issueKey}」の削除に失敗しました`;
    }
  }
  return await lineClient.replyMessage(event.replyToken, {
    type: 'text',
    text: replyMessage,
  });
};

export const handler = async (event: APIGatewayEvent) => {
  console.log(JSON.stringify(event));

  const signature = event.headers['x-line-signature'];
  if (!signature) {
    throw new Line.SignatureValidationFailed('no signature');
  }
  if (!Line.validateSignature(event.body!, LINE_CHANNEL_SECRET, signature)) {
    throw new Line.SignatureValidationFailed(
      'signature validation failed',
      signature
    );
  }

  const body: Line.WebhookRequestBody = JSON.parse(event.body!);
  await Promise.all(body.events.map(handleEvent)).catch((e) => {
    console.error(e);
    return { statusCode: 500 };
  });

  return { statusCode: 200 };
};

課題登録時のポイント

LINE から単にメッセージが送信された場合のイベントは、メッセージアクションとして処理します。下記の条件分岐では、上側の条件が true になります。

const handleEvent = async (event: Line.WebhookEvent) => {
  if (event.type === 'message' && event.message.type === 'text') {
    // ***
    await addTaskEvent(event);
  } else if (event.type === 'postback') {
    await deleteTaskEvent(event);
  }
};

LINE から送信されたメッセージを件名に、postIssue で課題の登録を行います。

const addTaskEvent = async (event: Line.MessageEvent) => {
  const message = event.message as Line.TextEventMessage;
  const { issueKey } = await backlog.postIssue({
    projectId: BACKLOG_PROJECT_ID,
    summary: message.text, // *** 件名
    priorityId: BACKLOG_PRIORITYID,
    issueTypeId: BACKLOG_ISSUE_TYPE_ID,
  });

  ...

課題の登録が完了した後はリプライを行います。確認テンプレート を活用して、文章の他にボタンを2つ付けています。

「確認する」ボタンについては、URIアクション、「削除する」ボタンについては、ポストバックアクション を設定しています。

ポストバックアクションが非常に便利で「削除する」ボタンがタップされた際、data に設定した値をリクエスト内容に含めて送信してくれます。

  ...

  const url = `https://${BACKLOG_HOST_NAME}/view/${issueKey}`;
  return await lineClient.replyMessage(event.replyToken, {
    type: 'template',
    altText: `課題「${issueKey}」を登録しました`,
    template: {
      type: 'confirm',
      text: `課題「${issueKey}」を登録しました`,
      actions: [
        {
          label: '確認する',
          type: 'uri',
          uri: url, // *** ボタンをタップすると、この URL が開かれる
        },
        {
          label: '削除する',
          type: 'postback',
          data: issueKey, // *** ボタンをタップすると、この値が送信される
          displayText: '削除する',
        },
      ],
    },
  });

課題削除時のポイント

「削除する」ボタンがタップされるとポストバックアクションとして送信されます。下記の条件分岐では、下側の条件が true になります。

const handleEvent = async (event: Line.WebhookEvent) => {
  if (event.type === 'message' && event.message.type === 'text') {
    await addTaskEvent(event);
  } else if (event.type === 'postback') {
    // ***
    await deleteTaskEvent(event);
  }
};

deleteIssueCount で課題の削除を行います。良い感じにエラーハンドリングも入れつつ、リプライを行います。

const deleteTaskEvent = async (event: Line.PostbackEvent) => {
  const issueKey = event.postback.data;
  let replyMessage = `課題「${issueKey}」を削除しました`;
  try {
    await backlog.deleteIssuesCount(issueKey);
  } catch (e: any) {
    if (e._status === 404) {
      replyMessage = `課題「${issueKey}」は既に削除済みです`;
    } else {
      replyMessage = `課題「${issueKey}」の削除に失敗しました`;
    }
  }
  return await lineClient.replyMessage(event.replyToken, {
    type: 'text',
    text: replyMessage,
  });
};

デプロイ

最後に下記でデプロイを行なって、完成です!

% cdk deploy "*" \
-c backlogApiKey=$BACKLOG_API_KEY \
-c lineAccessToken=$LINE_CHANNEL_ACCESS_TOKEN \
-c lineSecret=$LINE_CHANNEL_SECRET

おわりに

この LINE Bot を作ったことをきっかけに家庭のタスクをじゃんじゃん追加していこうと思います。もちろんタスクを積み上げるだけでなく、同じくらい消化もできるように頑張ります。いつだかぶっ壊してしまった洗面台の扉も早く直さないとな。

今回は以上になります。最後まで読んで頂きありがとうございました!