Bolt.jsโก + Firebase๐ฅ๊ธฐ์ ํฌ๊ณ ์ ์งํ๋ฅผ ์ ์ง๊ณํ ์ ์๋ Slack Bot์ ๋ง๋ค์์ต๋๋ค.
Slack์ ์ ์ฉ๋๋ ํ๋ ์์ Bolt์ ๋๋ค.js์Firebase๋ฅผ ์ฌ์ฉํ์ฌ ์์ ์ด ์ถ๋ ฅํ ์งํ๋ฅผ ํฉ์ฐํ์ฌ ์ข์ ๋๋์ผ๋ก ์ถ๋ ฅ์ ํฌ๋งทํ ์ ์๋ Bot๋ฅผ ๋ง๋ค์ด ๋ดค์ต๋๋ค. ๊ทธ๋์ ์๊ฐํด ๋๋ฆฌ๊ฒ ์ต๋๋ค.
๋ง๋ ๋ฌผ๊ฑด
์์ ์ ๊ธฐ์ ๋ก ํฌ๊ณ ํ ๊ณ์ ๋ช ์ ์ ๋ ฅํ๋ฉด ์ด๋ฐ ๋๋์์ ์งํ๋ฅผ ํฉ์ฐํด ๋ฐ๋ฉํ๋ ์ฌ๋๋ด.
๊ธฐ๋ฅ์ ๋ค์๊ณผ ๊ฐ์ต๋๋ค.
/report
๋ช
๋ น์ ์
๋ ฅํ๋ฉด ์
๋ ฅ ๋ชจ๋๊ฐ ์ด๋ฆฝ๋๋ค. ์๋น์ค
์งํ
ํธ์ํฐ ์, ํ๋ก์ ์, ํ๋ก์ ์
Qiita
๊ธฐ์ฌ ์, LGTM ์, ๊ด์ฌ ๋ถ์ผ ์
Zenn
๊ธฐ์ฌ ์, Like ์, ํ๋ก์ ์
note
๊ธฐ์ฌ ์, Like ์, ํ๋ก์ ์
๋๋ ์ด๋ป๊ฒ ์ฝ๊ฒ ์ ๋ ฅํ๊ณ ์ด๋ป๊ฒ ์์ ์ ์ง์ ์ ์ฝ๊ฒ ํ์ ํ๋์ง์ ์ฐฉ์ํ๋ค.
์ ๋ ฅ์ ๊ดํด์, ๋ชจ๋ ์ฌ๋ ๋ก๊ทธ์์์ ๋ง์ง๋ง์ผ๋ก ์ ๋ ฅํ ๋ด์ฉ์ Firestore์ ์ ์ฅํ๊ณ , ์ฒซ ๋ฒ์งธ ํ ์ดํ์๋ ์ ๋ ฅ ํ์์ค์ ์ด๊ธฐ ๊ฐ์ผ๋ก ์ ๋ ฅํฉ๋๋ค.
์ง์ ํ์ ์ ๊ดํด์๋ ์ดํ ์ดํ์ ์ง๋๋ฒ ์งํ์์ ์ฐจ์ด๋ฅผ ๋ํ๋ผ ๊ฒ์ด๋ค.๋ฐ๋ผ์ ์ผ์ฃผ์ผ์ ํ ๋ฒ์ฉ ์ง๋ น์ ๋ด๋ฆฌ๋ฉด ์ง๋์ฃผ์ ๊ฐ๋ณ๊ฒ ๋น๊ตํ ์ ์๋ค.
ํ๋ก๋น์ ๋
์ด๋ค ๊ตฌ์ฑ์ธ์ง ๋์ถฉ ์๊ฐํ ๊ฒ์.
Bolt.Firebase for Cloud Function์ผ๋ก js๋ฅผ ์ด๋ํ์ฌ Slack๊ณผ ๋ํํฉ๋๋ค.
๊ทธ๋ฐ ๋ค์ ์์์ ์ ๋ ฅ ๋ด์ฉ, ์งํ ๋ฐ์ดํฐ๋ฅผ Firestore์ ์ ์ฅํฉ๋๋ค.
์งํ ํต๊ณ ์น์ ์ ์๋น์ค๋ณ API ํฌ์ธํธ๋ฅผ ์ง์ ์ ์ด ์ฌ์ฉํ๋ค.
Zenn API ํด๋ผ์ด์ธํธ
functions/src/lib/zennClient.ts
import {
ZennArticle,
Follower,
ZennMyArticlesResponse,
ZennMyFollowersResponse,
} from "../types/zennTypes";
import axios from "axios";
import { ApiClient, ZennIndex } from "../types/types";
export class ZennClient implements ApiClient {
private readonly BASE_API_URL = "https://api.zenn.dev";
constructor(private userName: string) {
axios.defaults.baseURL = this.BASE_API_URL;
}
async fetchIndex(): Promise<ZennIndex> {
const articles = await this.fetchMyAllArticles();
const followers = await this.fetchMyFollowers();
return {
postCount: articles.length,
likeCount: this.tallyUpLikeCount(articles),
followerCount: followers.length,
};
}
private async fetchMyAllArticles(): Promise<ZennArticle[]> {
const response = await axios.get<ZennMyArticlesResponse>(
`/users/${this.userName}/articles`
);
return response.data.articles ?? [];
}
private async fetchMyFollowers(): Promise<Follower[]> {
let followers = [] as Follower[];
let hasNextPage = true;
try {
for (let page = 1; hasNextPage; page++) {
const response = await axios.get<ZennMyFollowersResponse>(
`/users/${this.userName}/followers?page=${page}`
);
hasNextPage = !!response.data.next_page;
followers = [...followers, ...response.data.users];
}
} catch (e) {
console.error(e);
}
return followers;
}
private tallyUpLikeCount(articles: ZennArticle[]): number {
return articles.reduce<number>((count, article) => {
return count + article.liked_count;
}, 0);
}
}
```ใ
Qiita์ฉ API ํด๋ผ์ด์ธํธfunctions/src/lib/qiitaClient.ts
import * as functions from "firebase-functions";
import axios from "axios";
import { QiitaItem, QiitaUser } from "../types/qiitaTypes";
import { ApiClient, QiitaIndex } from "../types/types";
export class QiitaClient implements ApiClient {
private readonly BASE_URL = "https://qiita.com/api/v2";
private readonly PER_PAGE = 100;
constructor(private userName: string) {
axios.defaults.baseURL = this.BASE_URL;
axios.defaults.headers["Authorization"] = `Bearer ${
functions.config().token.qiita
}`;
}
async fetchIndex(): Promise<QiitaIndex> {
const user = await this.fetchUser();
const items = await this.fetchAllItems(user);
const lgtmCount = this.tallyUpLgtmCount(items);
return {
postCount: user.items_count ?? 0,
lgtmCount: lgtmCount,
followerCount: user.followers_count ?? 0,
};
}
private async fetchUser() {
const response = await axios.get<QiitaUser>(`/users/${this.userName}`);
return response.data;
}
private async fetchAllItems(user: QiitaUser | null) {
if (!user) {
return [];
}
// ๆๅคงใใผใธๆฐ
const maxPage = Math.ceil(user.items_count / this.PER_PAGE);
// ๆ็จฟไธ่ฆงใฎๅๅพ
let allItems = [] as QiitaItem[];
await Promise.all(
[...Array(maxPage).keys()].map(async (i) => {
const items = await this.fetchItems(i + 1, this.PER_PAGE);
allItems = [...allItems, ...items];
})
);
return allItems;
}
private async fetchItems(page: number, perPage: number) {
const response = await axios.get<QiitaItem[]>(
`/items?page=${page}&per_page=${perPage}&query=user:${this.userName}`
);
return response.data;
}
private tallyUpLgtmCount(items: QiitaItem[]) {
const lgtmCount = items.reduce(
(result, item) => result + item.likes_count,
0
);
return lgtmCount;
}
}
te API ํด๋ผ์ด์ธํธ ์์functions/src/lib/noteClient.ts
import axios from "axios";
import {
NoteContent,
NoteContentsResponse,
NoteUserResponse,
} from "../types/noteTypes";
import { ApiClient, NoteIndex } from "../types/types";
export class NoteClient implements ApiClient {
private readonly BASE_URL = "https://note.com/api/v2";
constructor(private userName: string) {
axios.defaults.baseURL = this.BASE_URL;
}
async fetchIndex(): Promise<NoteIndex> {
const user = await this.fetchUser();
const contents = await this.fetchAllContent();
return {
postCount: user.noteCount ?? 0,
likeCount: this.tallyUpLikeCount(contents),
followerCount: user.followerCount ?? 0,
};
}
private async fetchUser() {
const response = await axios.get<NoteUserResponse>(
`/creators/${this.userName}`
);
return response.data.data;
}
private async fetchAllContent() {
let contents = [] as NoteContent[];
let isLastPage = false;
try {
for (let page = 1; !isLastPage; page++) {
const responseData = await this.fetchContents(page);
isLastPage = responseData.isLastPage;
contents = [...contents, ...responseData.contents];
}
} catch (e) {
console.log(e);
}
return contents;
}
private async fetchContents(page: number) {
const response = await axios.get<NoteContentsResponse>(
`/creators/${this.userName}/contents?kind=note&page=${page}`
);
return response.data.data;
}
private tallyUpLikeCount(contents: NoteContent[]) {
const likeCount = contents.reduce(
(result, content) => result + content.likeCount,
0
);
return likeCount;
}
}
Twitter API ํด๋ผ์ด์ธํธfunctions/src/lib/twitterClient.ts
import axios from "axios";
import { PublicMetrics, UsersResponse } from "../types/twitterTypes";
import * as functions from "firebase-functions";
import { ApiClient, TwitterIndex } from "../types/types";
export class TwitterClient implements ApiClient {
private readonly BASE_URL = "https://api.twitter.com/2";
constructor(private userName: string) {
axios.defaults.baseURL = this.BASE_URL;
axios.defaults.headers["Authorization"] = `Bearer ${
functions.config().token.twitter
}`;
}
async fetchIndex(): Promise<TwitterIndex> {
const metrics = await this.fetchUserMetrics();
return {
tweetCount: metrics.tweet_count,
followersCount: metrics.followers_count,
followingCount: metrics.following_count,
};
}
private async fetchUserMetrics(): Promise<PublicMetrics> {
const response = await axios.get<UsersResponse>(
`/users/by/username/${this.userName}?user.fields=public_metrics`
);
return response.data.data.public_metrics;
}
}
์ , ๋
ธํธ๊ฐ ๊ณต์ API๋ฅผ ๊ณต๊ฐํ์ง ์์๊ธฐ ๋๋ฌธ์ ์ธํฐ๋ท ์ ๋ณด์์ ๊ฒฝ๋ก, ์๋ต์ ๋ณด๊ณ ๊ตฌ์ถํ๋ค.๊ฐ ์๋น์ค์ ํฅํ ๊ฐ์ ์ ์ด์ฉํ ์ ์์ ๊ฐ๋ฅ์ฑ์ด ์๋ค.์ฝ๋๋ ๋ชจ๋ ์ฐฝ๊ณ ์์ ๊ณต๊ฐ๋๋ค.
API ํด๋ผ์ด์ธํธ ์์ธ ์ ๋ณด, Bolt.js์ ์ค์น ๋ฑ์ ์๋๋ฅผ ๋ณด์ญ์์ค.
๋งํ ๊ณณ์ ์ค์ฅํ๋ค
์ด๋ฒ ์ฌ๋๋ด ์์ ์์ ๋ฐํ ์ ์ด ์๋๋ฐ ์ ๊ฐ ์๊ฐํด ๋๋ฆด๊ฒ์.
FaaS์์ ๋ณผํธ.์ง๋ฌธ
์ค์ ๋ก ํด๋ผ์ฐ๋ ํ์ ํฌ ํ์ด์ด๋ฒ ์ด์ค์ AWS ๋๋ฐ๋ค ๋ฑ ํ์์ค์์ ๋ณผํธ.js์ ์ฌ๋๋ด ๊ตฌ์ฑ์ ๋ฐ๋ผ ๋ค์๊ณผ ๊ฐ์ ์ ์ฝ์ด ์์ด ์กฐ๊ธ ๊ณต์ ๋ค์ฌ์ผ ํ๋ค.
ack()
๋ฅผ ์ ๊ณตํฉ๋๋ค.ack()
๋ 200OK์ HTTP ์๋ต์ ๋ฐํํ๋ ํจ์๋ก, ์ด ํจ์๋ฅผ ์คํํ๋ฉด ๋น๋๊ธฐ์ ์ผ๋ก ํ์ ์ฒ๋ฆฌ๋ฅผ ํ ์ ์์ต๋๋ค.app.action('approve_button', async ({ ack, say }) => {
// ใใฎๆ็นใงใฌในใใณในใฏ่ฟใใใ
await ack();
// ไปฅ้ใฏ้ๅๆใงๅฆ็ใใใใ3็งไปฅๅ
ๅฟ็ญใฎๅถ็ดใฏใชใใ
await superHeavyTask()
});
๋จ, (2)์ ๊ฐ์ดFaaS๋ ์ฌ์ฉํ ์ ์์ต๋๋ค.์งํ
ack()
ํ๋ ์๊ฐ ๊ทธ ํ๊ฒฝ์ ์ฒ๋ฆฌ๊ฐ ์์ฑ๋ ๊ฒ์ผ๋ก ์ฌ๊ฒจ์ ธ ํ์ ์ฒ๋ฆฌ์ ์งํ์ ๋ณด์ฅํ ์ ์๊ธฐ ๋๋ฌธ์ด๋ค.์ด ๋ณผํธ์ ๋์ฒํ๊ธฐ ์ํด์js ์ธก์์๋
ack()
์ ์คํ์ ์ฒ๋ฆฌ ์๋ฃ๋ก ์ง์ฐ์ํค๋ ์ต์
processBeforeResponse
์ด ์์ง๋ง ์ด๊ฒ์ ์ค์ ํ๋๋ผ๋ (1) 3์ด ์ด๋ด์ ๊ท์น์ ์ง์ผ์ผ ํ๋ค.์ด๋ฒ Bot์ ๋ชจ๋๋ก ์์ ๋ ๊ฐ์ ์ด์ฉํ์ฌ ๊ฐ ์๋น์ค์ ์ ๊ทผํ API๋ฅผ ํตํด ๊ฒฐ๊ณผ๋ฅผ ์ง๊ณํ๊ธฐ ๋๋ฌธ์ ๋์ ํ 3์ด ์ด๋ด์ ์๋ต ๊ท์น์ ์งํค์ง ๋ชปํด ์๊ฐ ์ด๊ณผ ์ค๋ฅ๊ฐ ๋ฐ์ํ์ต๋๋ค.
๋๊ธฐํ ์ฒ๋ฆฌ๋ฅผ ํ๋ฉด ์๊ฐ ์ด๊ณผ ์ค๋ฅ๊ฐ ๋ฐ์ํ์ง๋ง Function ํจ์์์ ๋น๋๊ธฐ์ ์ผ๋ก ์คํํ๋ฉดFaaS์ ๋์์ธ์์ ์คํ์ ๋ณด์ฅํ ์ ์์ต๋๋ค. ๊ทธ๋ฌ๋ฉด ์ด๋ป๊ฒ ํฉ๋๊น?๐ค
Bolt.js Pythhon ๋ฒ์ bolt-python ์์ ์ด ๋ฌธ์ ๋ฅผ ํด๊ฒฐํ ์ ์์ ๊ฒ ๊ฐ์ต๋๋ค.์์ธํ ๋ด์ฉ์ ์๋๋ฅผ ๋ณด์ญ์์ค.
FaaS์์ ์ํํ๊ธฐ ์ํ Bolt for Python์ ๊ณผ์ - Qita
ํด๊ฒฐ์ฑ
์ด๋ฒ์๋Queue์ฑ์ผ๋กFirestore๋ฅผ ์ฌ์ฉํ๊ธฐ ๋๋ฌธ์Function ๋จ์๋ก ๋ชจ๋์ ์๋ต๊ณผ ํฉ๊ณ ์ฒ๋ฆฌ์ ์คํ์ ๋ถ๋ฆฌํ๋ ์ผ๋ก ์์ ํ ๋ฌธ์ ๋ฅผ ํํผํ์๋ค.
์ฒ๋ฆฌ์ ์คํ ์ ์ฐจ๋ ๋ค์๊ณผ ๊ฐ๋ค.
์ฌ์ ๋ช ๋ น ์คํ์์ ํ์ ๋ชจ๋๋ก
๋ชจ๋์์ ์งํ ํต๊ณ, ํต๋ก๋ก ํฌ๊ณ
ํนํ ์ค์ํ ๊ฒ์ ๋ชจ๋์ ๋ฐ์ก๋ถํฐ ์์๋๋ ์ฒ๋ฆฌ์ ๋๋ค. ์ฌ๊ธฐ์Firestore์ ๋ฐ์ดํฐ ์ ์ฅ->onCreate์ ์๋ก ๋ค๋ฅธ ํจ์๋ก ์์ํ๋ ๋ฐฉ๋ฒ์ผ๋ก Function ๋จ์๋ก ๋ถ๋ฆฌ ์ฒ๋ฆฌํ์ฌ ์๊ธฐ ์ ์ฝ์ ํผํฉ๋๋ค.
์ด๋ ๊ฒ ํ๋ฉด ์งํ ํต๊ณ๊ฐ ์ผ๋ง๋ ๊ฑธ๋ฆฌ๋ ์๊ด์์ด ๋ชจ๋์ ๋ํ ์๋ต์ด ์์ฑ๋์๊ธฐ ๋๋ฌธ์ ์๊ฐ์ ์ด๊ณผํ์ง ์๋๋ค.
๋๋งบ๋ค
์ด์ "Bolt.js"โก + Firebase๐ฅโ.
์ฐธ๊ฐํ ์ปค๋ฎค๋ํฐ์์ง๋์ด์ ์ธ์์ ์ฌ๋๋ ์ด์ฉ์ด ๊ฐ๋ฅํด ๊ธฐ์๋ค.
ํ์ํ์๋ฉด ๊ณต๊ฐ ์ฑ์ ์ฌ์ฉํด ๋ณด๊ณ ์ถ์ต๋๋ค.๋ฐ์์ ์ป์ ์ ์๋ค๋ฉด ๋๋ ๋งค์ฐ ๊ธฐ์ ๊ฒ์ด๋ค.
์ฐธ๊ณ ์๋ฃ
Reference
์ด ๋ฌธ์ ์ ๊ดํ์ฌ(Bolt.jsโก + Firebase๐ฅ๊ธฐ์ ํฌ๊ณ ์ ์งํ๋ฅผ ์ ์ง๊ณํ ์ ์๋ Slack Bot์ ๋ง๋ค์์ต๋๋ค.), ์ฐ๋ฆฌ๋ ์ด๊ณณ์์ ๋ ๋ง์ ์๋ฃ๋ฅผ ๋ฐ๊ฒฌํ๊ณ ๋งํฌ๋ฅผ ํด๋ฆญํ์ฌ ๋ณด์๋ค https://zenn.dev/ryo_kawamata/articles/build-slack-bot-with-bolt-firebaseํ ์คํธ๋ฅผ ์์ ๋กญ๊ฒ ๊ณต์ ํ๊ฑฐ๋ ๋ณต์ฌํ ์ ์์ต๋๋ค.ํ์ง๋ง ์ด ๋ฌธ์์ URL์ ์ฐธ์กฐ URL๋ก ๋จ๊ฒจ ๋์ญ์์ค.
์ฐ์ํ ๊ฐ๋ฐ์ ์ฝํ ์ธ ๋ฐ๊ฒฌ์ ์ ๋ (Collection and Share based on the CC Protocol.)
์ข์ ์นํ์ด์ง ์ฆ๊ฒจ์ฐพ๊ธฐ
๊ฐ๋ฐ์ ์ฐ์ ์ฌ์ดํธ ์์ง
๊ฐ๋ฐ์๊ฐ ์์์ผ ํ ํ์ ์ฌ์ดํธ 100์ ์ถ์ฒ ์ฐ๋ฆฌ๋ ๋น์ ์ ์ํด 100๊ฐ์ ์์ฃผ ์ฌ์ฉํ๋ ๊ฐ๋ฐ์ ํ์ต ์ฌ์ดํธ๋ฅผ ์ ๋ฆฌํ์ต๋๋ค