今朝のおススメの曲を届けてくれるLINE BOT


はじめに

 朝は、一日のはじまりなので、良いスタートを切りたい。
 そんなとき、心地よい音楽を聴いて、気分を整えたいと思います。
 LINEは、起き掛けにメッセージが入っていないか見るので、
 おススメの音楽が届いていたら、うれしい気がします。

完成イメージ

環境

  • Node.js
  • LINE Messaging API
  • Spotify SDK SpotifiApi
  • VSCode
  • Github Actions

作り方

  1. LINE Messaging APIの利用登録とチャネル作成*
  2. LINEログイン APIの利用登録とLIFFアプリ作成*
  3. Spotify APIの利用登録とWebアプリ作成*
  4. Github Actionsの設定*

*ほかのQiita記事に掲載あり

ソースコード

3. Webアプリ

Webアプリについて紹介します。

サーバ

/**
 * 今朝のおススメの曲
 */
// ライブラリ
const express = require('./node_modules/express');

// Spotify用設定
const spotifyApp = express();
spotifyApp.use(express.json());
spotifyApp.use(express.urlencoded({ extended: true }));
const spotifyRouter = require('./spotify-router');
spotifyApp.use('/', spotifyRouter);

// LINE用設定
const lineApp = express();
const lineRouter = require('./line-router');
lineApp.use('/line', lineRouter);

// Spotify用ポート
spotifyApp.listen(8888, () =>
  console.log(
    'HTTP Server up. Now go to http://localhost:8888/login in your browser.'
  )
);

// LINE用ポート
lineApp.listen(8000, () =>
  console.log(
    'HTTP Server up. Now go to http://localhost:8000/line/login in your browser.'
  )
);

Line用ルータ

/**
 * LINE用ルータ
 */
// ライブラリ
const express = require('./node_modules/express/index');
const line = require('@line/bot-sdk');
const axios = require('./node_modules/axios');
var util = require('./node_modules/util');

// LINE用設定
const app = express.Router();
const config = {
  channelSecret: '<シークレットキー>',
  channelAccessToken: '<アクセストークン>'
};
const client = new line.Client(config);

function makeFlexMessage(data) {
  var flexMessage = {
    type: "flex",
    altText: "this",
    contents: {}
  };
  var flexCarousel = {
    type: "carousel",
    contents: []
  };
  flexCarousel.contents = makeBubbles(data);
  flexMessage.contents = flexCarousel;

  return flexMessage;
}

function makeBubbles(data) {
  var flexBubbles = [];

  for(let i = 0; i < data.length; i++) {
    if (i > 2) break;
    flexBubbles.push(makeBubble(data[i]));
  }

  return flexBubbles;    
}

function makeBubble(data) {
  var flexBubble = {
    type: "bubble",
    size: "",
    direction: "",
    hero: {},
    body: {},
    footer: {}
  };

  flexBubble.size = "micro";
  flexBubble.direction = "ltr";
  flexBubble.hero = makeHero(data); // FlexImage;
  flexBubble.body = makeBody(data); // FlexBox;
  flexBubble.footer = makeFooter(data); // FlexBox;

  return flexBubble;
}

function makeHero(data) {
  var flexImage = {
    type: "image",
    url: "https://scdn.line-apps.com/n/channel_devcenter/img/flexsnapshot/clip/clip10.jpg",
    size: "full",
    aspectRatio: "320:213",
    aspectMode: "cover"
  };
 
  return flexImage;
}

function makeBody(data) {
  var flexBox = {
    type: "box",
    layout: "vertical",
    contents: [],
    spacing: "sm",
    paddingAll: "13px"
  };
  flexBox.contents.push(makeBodyText(data)); //FlexComponent[]
  flexBox.contents.push(makeBodyHeader(data));
  flexBox.contents.push(makeBodyMain(data));

  return flexBox;
}

function makeBodyText(data) {
  var flexText = {
    type: "text",
    text: data.name,
    size: "sm",
    wrap: true,
    weight: "bold",
    style: "normal",
  };

  return flexText;
}

function makeBodyHeader(data) {
  var flexBox = {
    type: "box",
    layout: "baseline",
    contents: []
  };

 var stars = Math.round(data.popularity/10);
  if (stars > 5) {
    stars = 5;
  }

  for(let i = 0; i < stars; i++) {
    flexBox.contents.push(makeBodyHeaderEvaluationIcon(data)); //FlexComponent[];
  }
  flexBox.contents.push(makeBodyHeaderEvaluationNumber(data)); //FlexComponent[];

  return flexBox;
}

function makeBodyHeaderEvaluationIcon(data) {
  var flexIcon = {
    type: "icon",
    url: "https://scdn.line-apps.com/n/channel_devcenter/img/fx/review_gold_star_28.png",
    size: "xs"
  };

  return flexIcon;
}

function makeBodyHeaderEvaluationNumber(data) {
  var flexText = {
    type: "text",
    text: "",
    size: "xs",
    color: "#8c8c8c",
    margin: "md",
    flex: 0
  };
 
  var value = data.popularity;
  if (value === null || value === undefined) {
    value = " "; 
  }
  flexText.text = "'" + value + "'";

  return flexText;
}

function makeBodyMain(data) {
  var flexBox = {
    type: "box",
    layout: "vertical",
    contents: []
  };
  flexBox.contents.push(makeBodyMainContext(data)); //FlexComponent[];

  return flexBox;
}

function makeBodyMainContext(data) {
  var flexBox = {
    type: "box",
    layout: "baseline",
    spacing: "sm",
    contents: []
  };

  var flexText = {
    type: "text",
    text: data.artist,
    size: "xs",
    wrap: true,
    color: "#8c8c8c",
    flex: 5
  };

  flexBox.contents.push(flexText);

  return flexBox;
}

function makeFooter(data) {
  var flexBox = {
    type: "box",
    layout: "vertical",
    spacing: "sm",
    contents: []
  };

var flexButton = {
    type: "button",
    style: "primary",
    color: "#228b22",
    action: ""
  };

  var action = {};
  action.label = "Play";
  action.type = "uri";
  action.uri = data.url;
  flexButton.action = action;

  flexBox.contents.push(flexButton);

  return flexBox;
}

async function handleEvent(event) {
  if (event.type !== 'message' || event.message.type !== 'text') {
    return Promise.resolve(null);
  }
  var msg = '今朝のおススメの曲';
  let url = "http://localhost:8888/getmusic";
  let response = await axios.get(url);

  var flexMessage = makeFlexMessage(response.data);
  console.log(util.inspect(flexMessage));

// ユーザーにリプライメッセージを送ります。
  return client.replyMessage(event.replyToken, flexMessage);
}

//=================
// ルータ
//=================

app.get('/', (req, res) => res.send('Hello LINE BOT! (HTTP GET)'));
app.post('/webhook', line.middleware(config), (req, res) => {

  if (req.body.events.length === 0) {
    res.send('Hello LINE BOT! (HTTP POST)');
    console.log('検証イベントを受信しました!');
    return;
  } else {
    console.log('受信しました:', req.body.events);
  }

  Promise.all(req.body.events.map(handleEvent)).then((result) => res.json(result));
});

module.exports = app;

Spotify用ルータ

/**
 * Spotify用ルータ
 */
// ライブラリ
const ServerMethods = require('./src/server-methods');
const SpotifyWebApi = require('./src/spotify-web-api');
const express = require('./node_modules/express');
var util = require('./node_modules/util');

// Spotify用設定
const app = express.Router();
app.use(express.json());
app.use(express.urlencoded({ extended: true }));

// 操作 ※ただし、Primeプラン限定あり
const scopes = [
  'ugc-image-upload',
  'user-read-playback-state',
  'user-modify-playback-state',
  'user-read-currently-playing',
  'streaming',
  'app-remote-control',
  'user-read-email',
  'user-read-private',
  'playlist-read-collaborative',
  'playlist-modify-public',
  'playlist-read-private',
  'playlist-modify-private',
  'user-library-modify',
  'user-library-read',
  'user-top-read',
  'user-read-playback-position',
  'user-read-recently-played',
  'user-follow-read',
  'user-follow-modify'
];

// Spotify API SDK 設定
SpotifyWebApi._addMethods(ServerMethods);
const spotifyApi = new SpotifyWebApi({
  redirectUri: 'http://localhost:8888/callback',
  clientId: process.argv.slice(2)[0], // <クライアントID>
  clientSecret: process.argv.slice(2)[1] // <クライアントシークレット>
});

//=================
// ルータ
//=================
// ログイン
app.get('/login', (req, res) => {
  res.redirect(spotifyApi.createAuthorizeURL(scopes));
});

// Spotify からのコールバック
app.get('/callback', (req, res) => {
  const error = req.query.error;
  const code = req.query.code;
  const state = req.query.state;

  if (error) {
    console.error('Callback Error:', error);
    res.send(`Callback Error: ${error}`);
    return;
  }

  // トークン取得
  spotifyApi
    .authorizationCodeGrant(code)
    .then(data => {
      const access_token = data.body['access_token'];
      const refresh_token = data.body['refresh_token'];
      const expires_in = data.body['expires_in'];

      spotifyApi.setAccessToken(access_token);
      spotifyApi.setRefreshToken(refresh_token);

      console.log('access_token:', access_token);
      console.log('refresh_token:', refresh_token);

      console.log(
        `Sucessfully retreived access token. Expires in ${expires_in} s.`
      );
      res.send('Success! You can now close the window.');

      // トークン・リフレッシュ※期限の半分を過ぎたら
      setInterval(async () => {
        const data = await spotifyApi.refreshAccessToken();
        const access_token = data.body['access_token'];

        console.log('The access token has been refreshed!');
        console.log('access_token:', access_token);
        spotifyApi.setAccessToken(access_token);
      }, expires_in / 2 * 1000);
    })
    .catch(error => {
      console.error('Error getting Tokens:', error);
      res.send(`Error getting Tokens: ${error}`);
    });
});

// 今朝のおススメ曲取得※20曲
app.get('/getmusic', (req, res) => {

  spotifyApi
  .searchTracks('Good Morning')
  .then(function(data) {
    // Print some information about the results
    console.log('I got ' + data.body.tracks.total + ' results!');

    // Go through the first page of results
    var firstPage = data.body.tracks.items;
    console.log('The tracks in the first page are (popularity in parentheses):');

    let results = [];
    firstPage.forEach(function(track, index) {
        results.push({'index'       :index
                    , 'name'        :track.name
                    , 'duration'    :Math.round(track.duration_ms/60000)
                    , 'popularity'  :track.popularity
                    , 'url'         :track.external_urls.spotify
                    , 'preview_url' :track.preview_url
                    , 'artist'      :track.artists[0].name
                    });
    });
    results.sort(function(first, second){
        return second.popularity - first.popularity;
    });
    console.log(util.inspect(results));
    return res.json(results);
  }).catch(function(err) {
    console.log('Something went wrong:', err.message);
  });
});

module.exports = app;

おわりに

 LINE公式アプリを見ると、FlexMessage や メニュー を上手く使用して UX を高めているのがわかります。
 見習いたいですね。
 デバッグについて書き添えます。
 FlexMessage は、
 1. LINE FlexMessage Simulator で作成して、コーディングして、
 2. コンソール等のログにメッセージのJSONを出力して、
 3. それを LINE FlexMessage Simulator で正しく表示されるかを確認するとよいと思います。
 コードだけだとエラーがわかりにくいです。

参考