ใน Codelab นี้คุณจะได้เรียนรู้การสร้างตู้เพลง Jukebox Chatbot เท่ห์ๆใน LINE ผ่าน Spotify API เพื่อเอาไว้ฟังเพลงด้วยกันกับเพื่อนๆ โดยสามารถค้นหาและเพิ่มเพลงลงใน Playlist ซึ่งเมื่อเราเริ่มเล่นเพลงใน Playlist นี้เวลามีเพลงใหม่ๆที่ถูกเพิ่มเข้ามา มันก็จะไปลงในคิวและเล่นให้อัตโนมัติ
จุดเริ่มต้นของการพัฒนา LINE Chatbot คือคุณจะต้องสร้าง LINE OA(LINE Official Account) และเปิดใช้งาน Messaging API
หลังจากที่เรามี LINE OA เรียบร้อยแล้ว ขั้นตอนนี้จะพาทุกคนไปเพิ่มความสามารถให้ LINE OA ของเรากลายเป็น LINE Chatbot ได้
ขั้นตอนนี้เราจะเข้าไปใช้งาน LINE Developers Console ซึ่งเป็นเว็บไซต์สำหรับการบริหารจัดการ LINE Chatbot(LINE OA ที่เปิดใช้งาน Messaging API แล้ว) ในส่วนของนักพัฒนา
เบื้องหลังของ Chatbot ตัวนี้ เราจะใช้บริการ Spotify API ทั้งในการสร้าง Playlist ที่จะเล่นและในการค้นหาเพลงต่างๆ ให้เรามาสร้าง ‘แอป' บน Spotify Developer Portal เพื่อที่สามารถเข้าใช้งาน API ของ Spotify ได้
เบื้องหลังของ Chatbot ตัวนี้ เราจะใช้บริการใน Firebase อย่าง Cloud Functions for Firebase ดังนั้นขั้นตอนนี้เราจะมาสร้างโปรเจค Firebase เพื่อใช้งานกัน
เนื่องจาก Cloud Functions for Firebase มีเงื่อนไขว่า หากต้องการไป request ตัว APIs ที่อยู่ภายนอก Google คุณจำเป็นจะต้องใช้ Blaze plan(เราจะต้องไปเรียก Messaging API ของ LINE)
Firebase CLI เป็นเครื่องมือที่จำเป็นสำหรับการ deploy ตัวฟังก์ชันที่เราพัฒนาขึ้น อีกทั้งยังสามารถจำลองการทำงานฟังก์ชัน(Emulate) ภายในเครื่องที่เราพัฒนาอยู่(Locally) ได้
npm install -g firebase-tools
firebase --version
firebase login
mkdir jukeboxbot
cd jukeboxbot
firebase init functions
ขั้นตอนนี้เราจะมาสร้าง Utility Class ที่ชื่อว่า spotify.js ที่จะคอยช่วยจัดการเชื่อมต่อกับ API ของ Spotify โดยจะจัดการตั้งแต่สร้าง URL ให้ผู้ใช้ Login เข้าใช้งาน Spotify , สร้าง Playlist ให้ (ถ้ายังไม่มี), ค้นหาเพลงและเพิ่มเพลงไปยัง Playlist
package.json
ขี้นมาaxios
และ spotify
ลงไปใน dependencies
"dependencies": {
"firebase-admin": "^9.7.0",
"firebase-functions": "^3.13.2",
"axios": "^0.21.1",
"spotify-web-api-node": "^5.0.2"
}
functions
จากนั้นสั่ง Install ตัว dependencies ที่เพิ่มเข้ามาใหม่ใน terminal ด้วยคำสั่งnpm install
spotify.js
ในโฟลเดอร์เดิมที่ชื่อ functions
และเขียนโค้ดตามตัวอย่างด้านล่างconst SpotifyWebApi = require("spotify-web-api-node");
class Spotify {
constructor() {
// Init การเชื่อมต่อกับ Spotify
this.api = new SpotifyWebApi({
clientId: 'your-spotify-client-id',
clientSecret: 'your-spotify-client-secret',
redirectUri: 'https://us-central1-xxxxx.cloudfunctions.net/LineWebhook'
});
// สร้าง Login URL เพื่อสามารเข้าถึงสิทธิต่างๆของเครืื่องหลักที่ต่อลำโพง
const scopes = ["playlist-read-private", "playlist-modify", "playlist-modify-private"];
const authorizeUrl = this.api.createAuthorizeURL(scopes, "default-state");
console.log(`Authorization required. Please visit ${authorizeUrl}`);
}
isAuthTokenValid() {
if (this.auth == undefined || this.auth.expires_at == undefined) {
return false;
}
else if (this.auth.expires_at < new Date()) {
return false;
}
return true;
}
async initialized() {
const playlists = [];
const limit = 50;
let offset = -limit;
let total = 0;
// ทำการ Download Playlist ทั้งหมดจาก Spotify ของผู้ใช้ที่ได้ Login เก็บไว้ในตัวแปร playlists
do {
offset += limit;
const result = await this.api.getUserPlaylists(undefined, { offset: offset, limit: 50 });
total = result.body.total;
const subset = result.body.items.map((playlist) => {
return { id: playlist.id, name: playlist.name };
});
playlists.push(...subset);
} while ((offset + limit) < total);
// ทำการค้นหา Playlist ที่ชื่อว่า 'SpotifyJukebox'
// ถ้าไม่เจอ ให้ทำการสร้าง Playlist ขึ้นมาใหม่
const index = playlists.findIndex((playlist) => playlist.name === 'SpotifyJukebox');
if (index >= 0) {
this.playlist = playlists[index].id;
console.log('Playlist already exist');
}
else {
let result;
// สร้าง Playlist ใหม่
await this.api.createPlaylist('SpotifyJukebox', { 'description': 'My Jukebox Chatbot', 'public': true }).then(function(data) {
result = data.body.id;
console.log('Created playlist! ' + result);
}, function (err) {
console.log('Something went wrong!', err);
});
this.playlist = result;
}
console.log("Spotify is ready!");
}
async refreshAuthToken() {
const result = await this.api.refreshAccessToken();
const expiresAt = new Date();
expiresAt.setSeconds(expiresAt.getSeconds() + result.body.expires_in);
this.settings.auth.access_token = result.body.access_token;
this.settings.auth.expires_at = expiresAt;
this.api.setAccessToken(result.body.access_token);
}
async receivedAuthCode(authCode) {
// ได้รับ Authorization code ตอนที่ Call back URL ถูกเรียก
// จากนั้นเอา Code นี้ไปรับ Access token กับ Refresh token อีกที
const authFlow = await this.api.authorizationCodeGrant(authCode);
this.auth = authFlow.body;
// เก็บค่่าของ expire time ไว้ใช้ตอนเรียก refresh token
const expiresAt = new Date();
expiresAt.setSeconds(expiresAt.getSeconds() + authFlow.body.expires_in);
this.auth.expires_at = expiresAt;
// ส่งค่า Tokens ทั้งสองให้กับ library ของ Spotify
this.api.setAccessToken(authFlow.body.access_token);
this.api.setRefreshToken(authFlow.body.refresh_token);
// เริ่มทำการ Init การเชื่อมต่อกับ Spotify
this.initialized();
}
async searchTracks(terms, skip = 0, limit = 10) {
if (!this.isAuthTokenValid()) {
await this.refreshAuthToken();
}
const result = await this.api.searchTracks(terms, { offset: skip, limit: limit })
return result.body.tracks;
}
async queueTrack(track) {
if (!this.isAuthTokenValid()) {
await this.refreshAuthToken();
}
return this.api.addTracksToPlaylist(this.playlist, [`spotify:track:${track}`]);
}
}
module.exports = new Spotify();
clientId
และ clientSecret
ที่ได้ใน Spotify dashboard ก่อนหน้านี้มาแทนที่ในโค้ด redirectUri
โดยสามารถดูได้จากหน้า Project setting ใน Firebase consoleขั้นตอนนี้เราจะมาสร้าง Utility Class ที่ช่วยจัดการส่วนที่เกี่ยวข้องกับ LINE โดยจะเรียงลำดับผลลัพธ์ของการค้นหาเพลง และทำการปั้น Flex message สวยๆ รวมไปถึงจัดการ Postback event เมื่อผู้ใช้ทำการกดปุ่มจาก Flex message เข้ามา
สร้างไฟล์ใหม่ชื่อ lineapp.js
ในโฟลเดอร์เดิมที่ชื่อ functions
และเขียนโค้ดตามตัวอย่างด้านล่าง
const spotify = require("./spotify");
const axios = require("axios");
const LINE_HEADER = {
"Content-Type": "application/json",
Authorization: "Bearer xxxxxxx"
}
const Commands = {
ADD_TRACK: "ADD_TRACK",
SEARCH_MORE: "SEARCH_MORE"
}
class lineApp {
async receivedPostback(event) {
const payload = JSON.parse(event.postback.data);
switch (payload.command) {
case Commands.ADD_TRACK: {
// เพิ่มเพลงที่ผู้ใช้เลือกใน Flex message เข้าไปใน Playlist
return this.queueMusic(payload.track);
}
case Commands.SEARCH_MORE: {
// เรียกเมธอด searchMusic อีกครั้งพร้อมกับส่ง parameters ที่อยู่ใน payload
return this.searchMusic(payload.terms, payload.skip, payload.limit);
}
}
}
async queueMusic(track) {
await spotify.queueTrack(track);
const message = {
type: "flex",
altText: "Thanks! Your track has been added.",
contents:
{
type: "bubble",
size: "kilo",
body: {
type: "box",
layout: "vertical",
contents: [
{
type: "text",
contents: [
{
type: "span",
text: "Thanks! ",
color: "#1DB954",
weight: "bold",
size: "md"
},
{
type: "span",
text: "Your track has been added to the BrownJukebox playlist 🎶",
color: "#191414"
}
],
wrap: true
}
]
},
styles: {
body: {
backgroundColor: "#FFFFFF"
}
}
}
};
return message;
}
async searchMusic(terms, skip = 0, limit = 10) {
// ทำการค้นหาเพลง โดยค่อยๆดึงทีละ 10 เพลง
const queryBegin = skip;
const queryEnd = limit;
const result = await spotify.searchTracks(terms, queryBegin, queryEnd);
if (result.items.length > 0) {
// ถ้ายังมีผลลัพธ์เหลืออยู่ จะแสดงปุ่ม 'More' ใน Flex message เพื่อให้ผู้ใช้ค้นหาเพลงเพิ่มเติม
const remainingResults = result.total - limit - skip;
const showMoreButton = (remainingResults > 0);
// จัดเรียงผลลัพธ์ตามความนิยม
result.items.sort((a, b) => (b.popularity - a.popularity));
const message = {
type: "flex",
altText: "Your Spotify search result",
contents: {
type: "bubble",
size: "giga",
header: {
type: "box",
layout: "horizontal",
contents: [
{
type: "image",
url: "https://bcrm-i.line-scdn.net/bcrm/uploads/1557539795/public_asset/file/853/1591094107652078_Spotify_Icon_RGB_White.png",
align: "start",
size: "xxs",
flex: 0,
aspectRatio: "4:3"
},
{
type: "text",
text: "Powered by Spotify",
color: "#ffffff",
size: "xxs",
align: "end",
gravity: "center",
position: "relative",
weight: "regular"
}
],
paddingAll: "10px"
},
body: {
type: "box",
layout: "vertical",
contents: [],
backgroundColor: "#191414",
spacing: "md"
},
styles: {
header: {
backgroundColor: "#1DB954"
}
}
}
};
// เพิ่มปุ่ม 'More' หากมีผลลัพธ์เพิ่มเติม โดยแปะข้อมูลที่จำเป็นใน payload ด้วยเผื่อในกรณีที่ผู้ใช้ต้องการค้นหาอีก
if (showMoreButton) {
message.contents.footer = this.generateMoreButton({
command: Commands.SEARCH_MORE,
terms: terms,
skip: skip + limit,
limit: limit
})
}
// นำผลลัพธ์ที่ได้มาแสดงใน Flex message โดยวนลูปสร้างทีละเพลง
message.contents.body.contents = result.items.map((track) => {
this.sortTrackArtwork(track);
return {
type: "box",
layout: "horizontal",
contents: [
{
type: "box",
layout: "vertical",
contents: [
{
type: "image",
aspectRatio: "4:3",
aspectMode: "cover",
url: track.album.images.length > 0 ? track.album.images[0].url : ""
}
],
flex: 0,
cornerRadius: "5px",
width: "30%",
spacing: "none"
},
{
type: "box",
layout: "vertical",
contents: [
{
type: "text",
size: "md",
color: "#1DB954",
style: "normal",
weight: "bold",
text: track.name,
wrap: true
},
{
type: "text",
size: "xxs",
wrap: true,
color: "#FFFFFF",
text: this.generateArtistList(track)
}
],
spacing: "none",
width: "40%"
},
{
type: "box",
layout: "vertical",
contents: [
{
type: "button",
action: this.generatePostbackButton("Add", { command: Commands.ADD_TRACK, track: track.id }),
style: "primary",
gravity: "bottom",
color: "#1DB954"
}
],
spacing: "none",
width: "20%"
}
],
backgroundColor: "#191414",
spacing: "xl",
cornerRadius: "5px"
}
});
return message;
}
}
generatePostbackButton(title, payload) {
return {
type: "postback",
label: title,
data: JSON.stringify(payload)
};
}
generateMoreButton(payload) {
return {
type: "box",
layout: "vertical",
contents: [
{
type: "button",
action: {
type: "postback",
label: "More",
data: JSON.stringify(payload)
},
style: "secondary"
}
],
backgroundColor: "#191414"
};
}
generateArtistList(track) {
// ในกรณีที่เพลงนั้นๆอาจจะมีชื่อศิลปินหลายคน จะ list ชื่อของศิลปินแต่ละคน ตามด้วย comma
let artists = "";
track.artists.forEach((artist) => {
artists = artists + ", " + artist.name;
});
artists = artists.substring(2);
return artists;
}
sortTrackArtwork(track) {
// จัดเรียงภาพอัลบั้มตามขนาด จากขนาดเล็กไปขนาดใหญ่ (ascending)
track.album.images.sort((a, b) => {
return b.width - a.width;
});
}
async replyMessage(replyToken, message) {
try {
return await Promise.resolve(axios({
method: "post",
url: 'https://api.line.me/v2/bot/message/reply',
headers: LINE_HEADER,
data: JSON.stringify({
replyToken: replyToken,
messages: [message]
})
}))
} catch (error) {
console.error(`Delivery to LINE failed (${error})`);
}
}
}
module.exports = new lineApp();
LineWebhook
ในไฟล์ index.js
ที่ทำหน้าที่จัดการรับ Webhook ของผู้ใช้และรอรับ Callback จาก Spotifyconst functions = require("firebase-functions");
const spotify = require("./spotify");
const lineApp = require("./lineapp");
exports.LineWebhook = functions.https.onRequest(async (req, res) => {
if (req.query.code !== undefined) {
// กรณีผู้ใช้ทำการ Login ด้วย Spotify จะมี Callback กลับมาเพื่อทำการเชื่อมต่อกับ Spotify API
spotify.receivedAuthCode(req.query.code);
res.status(200).send("Login Successfully!");
} else {
let event = req.body.events[0];
let message;
if (event.type === 'message' && event.message.type === 'text') {
// กรณีผู้ใช้พิมพ์ข้อความเพื่อค้นหาตามชื่อเพลง/ศิลปิน
message = event.message.text;
let searchInput = event.message.text;
message = await lineApp.searchMusic(searchInput);
} else if (event.type === 'postback') {
// กรณีผู้ใช้กดปุ่ม Add (เพื่อเพิ่มเพลง) หรือกดปุ่ม More (เพื่อค้นหาเพลงเพิ่มเติม)
message = await lineApp.receivedPostback(event)
}
await lineApp.replyMessage(event.replyToken, message);
return res.status(200).send(req.method);
}
});
firebase deploy --only functions
ให้เอา URL ของ Cloud Functions /LineWebhook
ไปใส่ใน Webhook settings ของ Messaging API Channel ที่เราสร้างไว้ในขั้นตอนที่ 2
กลับไปที่หน้า Spotify Developer Dashboard โดยเราต้องมาตั้งค่า Callback URL เมื่อผู้ใช้ทำการ Login เสร็จตัว authorization code จะถูกส่งไปยัง URL endpoint นี้ซึ่งเราต้องการใช้ในการรับ Access token/Refresh token อีกที
ให้เราทำการกดปุ่ม Edit Settings > Redirect URIs > ใส่ URL เดียวกัน
https://accounts.spotify.com/authorize?client_id=yyyyyy&response_type=code&redirect_uri=https://us-central1-xxxxxx.cloudfunctions.net/LineWebhook&scope=playlist-read-private%20playlist-modify%20playlist-modify-private&state=default-state
ยินดีด้วย! ถึงตรงนี้คุณก็มี LINE Chatbot ที่เป็นตู้เพลง Jukebox เท่ห์ๆของคุณเองแล้ว!!!