5
Help us understand the problem. What are the problem?
Organization

今朝のおススメの曲を届けてくれる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 で正しく表示されるかを確認するとよいと思います。
 コードだけだとエラーがわかりにくいです。

参考

Register as a new user and use Qiita more conveniently

  1. You can follow users and tags
  2. you can stock useful information
  3. You can make editorial suggestions for articles
What you can do with signing up
5
Help us understand the problem. What are the problem?