この記事は、東京大学工学部電子情報工学科・電気電子工学科の後期実験「大規模ソフトウェアを手探る」の最終レポートとして作成されました。
Rocket.Chatとは
Rocket.Chatは、OSSのビジネスコミュニケーションソフトウェアです。 ビジネスコミュニケーションツールの代表格としてSlackが挙げられますが、Rocket.ChatはOSSの強みを活かし、Slackにはない機能や高い拡張性を備えています。
今回はそのRocket.Chatをより便利に使えるようにするために新たな機能を追加したのでその紹介をしたいと思います。
環境構築
実際に試した環境は、Ubuntu 20.04.6 LTSとMacOS M1 です。
まずこちらのGitHubを手元にクローンします。
次にこちらのREADMEを参考にbuildを行います。
1. Node 14.x (LTS)をインストールします。nodeのホームページやhomebrewからもインストールできますが、nvmやvoltaといったnodeのバージョン管理ツールでインストールすることをお勧めします。
nvm install 14
nvmで新しいnodeのバージョンをインストールし、コンピュータに元々入っていたnodeのバージョンから新しいバージョンに変更しようとしたところ、変更されないという現象が起こりました。そこで、一旦既存のnodeをアンインストールし、再び試すと成功しました。この現象が起こった理由として、既存のnodeがnvmでインストールされたものではなく、nodeのホームページからインストールされたものであるために競合が起こったからだと考えられました。
2. Meteorをnpmでインストールする。バージョンはここを参照します。
npm install -g meteor@バージョン番号
3. yarnをnpmでインストールする。
npm install -g yarn
4. yarn
コマンドで依存関係をインストールする。
5. yarn dev
または yarn dsv
でbuild開始
(私たちの環境では、yarn dsvでしか上手くbuildできませんでした。)
機能一覧
- メッセージの文字の色を変える
- 時間差投稿(指定した時間に投稿)
- 送られてきたメッセージの英訳
- 返信の自動作成(ChatGPT APIを利用)
- 送るメッセージの校正(ChatGPT APIを利用)
GUIの変更
この章では主にUIを変更して付け加えた機能について書きます。
追加した機能は主に
- 文字の色を変える機能
- 投稿を予約する機能
の二つです。
詳細な機能は以下です。
文字の色を変えるのは<(color=red){Content}>
というような構文をフロントエンド側で解析してContentの色を変える
スケジュールは<schedule> 1h 1m 1s {Content}
というようなコマンドをバックエンドで解析してDBに反映するのを指示されただけ遅らせる、またYY-MM-DD hh : mm : ssのような時間指定も解析可能
最初の機能のアップデートです。
以上の機能を、実際にどう実装したか見ていきましょう。
文字の色を変えるコマンドの実装
まず、このRocket.ChatというアプリはMarkdownを自動で解析して表示したり、太字や斜体にするための独自のコマンドを持っていました。
そこで、それらの構文を解析する場所に、新しく色を解析する機能を追加すればいいのではないかと考えました。
そこで見つけたのが、GazzodownTextというファイルです。
このファイルで定義されるのは、BEから送られてきたJSON形式で太字かどうか、斜体かどうかなどの情報を表されたメッセージを、解析して適切なHTML構文に直す関数に渡す関数です。
これに、色の構文が検出されればメッセージ部分を直接<span style={{color:"red"}}>{Content}</span>
のように変更する機能も付ければいいと考えました。
const transformTextWithColor = (text: string): React.ReactNode[] => {
const nodes: React.ReactNode[] = [];
let currentIndex = 0;
while (currentIndex < text.length) {
const patternStart = text.indexOf('<(color==', currentIndex);
if (patternStart === -1) {
nodes.push(text.slice(currentIndex));
break;
}
if (patternStart > currentIndex) {
nodes.push(text.slice(currentIndex, patternStart));
}
const colorStart = patternStart + 9;
const colorEnd = text.indexOf(')', colorStart);
const contentEnd = text.indexOf('>', colorEnd);
if (colorEnd === -1 || contentEnd === -1) {
nodes.push(text.slice(currentIndex));
break;
}
const color = text.slice(colorStart, colorEnd);
const content = text.slice(colorEnd + 1, contentEnd);
nodes.push(<span key={patternStart} style={{ color }}>{content}</span>);
currentIndex = contentEnd + 1;
}
return nodes;
};
type Token = {
type: string;
value: string | React.ReactNode | Token[];
};
const transformTokens = (tokens: Token[]): Token[] => {
return tokens.map((token) => {
console.log(token)
// PLAIN_TEXTの場合のみ変換を行う
if (token.type === "PLAIN_TEXT") {
return {
...token,
value: transformTextWithColor(token.value as string),
};
}
// その他のトークンタイプに関して、valueが配列(つまり、ネストされたトークンが存在する)場合
// 再帰的にこの関数を適用する
if (Array.isArray(token.value)) {
return {
...token,
value: transformTokens(token.value as Token[]),
};
}
// 上記の条件に合致しないトークンはそのまま返す
return token;
});
};
const transformedChildren = React.cloneElement(children, {
tokens: transformTokens(children.props.tokens),
});
これが追加した部分です。バックエンドから送られてきたJSON形式のメッセージは、玉ねぎのように太字や斜体などの情報の皮で囲まれていて、最深部にPLAIN_TEXTがあります。
これに、色の構文が含まれるかを検出し、含まれるならReactnodeで囲んでコンポーネントにしてやって、前述のHTMLに直す関数に渡すことで、上手く行きました。
spanではなくReactnodeで囲むのは、Reactではテキスト中のHTML構文をエスケープするためです。
スケジュール構文解析機能の実装(バックエンド)
スケジューリングはバックエンド側でメッセージを解析して、スケジューリングをしめす構文があれば、DBに登録するのを遅らせるという方針で実装しました。
export async function executeSendMessage(uid: IUser['_id'], message: AtLeast<IMessage, 'rid'>, previewUrls?: string[]) {
if (message.msg.startsWith('<schedule>')) {
const parts = message.msg.split(' ');
const timeStr = parts.slice(1).find(p => p.includes('h') || p.includes('m') || p.includes('s'));
const scheduledMessage = parts.slice(parts.indexOf(timeStr) + 1).join(' ');
let delay;
let whichsyntax=0;
// Check if it's relative time like 2h, 30m, or 15s
if (timeStr) {
delay = convertToMilliseconds(timeStr);
whichsyntax=1;
} else {
// Assuming format: YYYY-MM-DD HH:mm:ss for absolute time
const scheduledDate = new Date(`${parts[1]} ${parts[2]}`);
delay = scheduledDate.getTime() - new Date().getTime();
whichsyntax=2;
}
if (delay > 0) {
setTimeout(async () => {
message.msg = scheduledMessage;
await actualSendMessageLogic(uid, message, previewUrls);
}, delay);
return;
}
}
await actualSendMessageLogic(uid, message, previewUrls);
}
function convertToMilliseconds(timeStr: string): number {
let totalMilliseconds = 0;
const hours = timeStr.match(/(\d+)h/);
if (hours) {
totalMilliseconds += parseInt(hours[1]) * 60 * 60 * 1000;
}
const minutes = timeStr.match(/(\d+)m/);
if (minutes) {
totalMilliseconds += parseInt(minutes[1]) * 60 * 1000;
}
const seconds = timeStr.match(/(\d+)s/);
if (seconds) {
totalMilliseconds += parseInt(seconds[1]) * 1000;
}
return totalMilliseconds;
}
actualSendMessageLogic関数(実際にDBに登録する関数)にメッセージを渡す前に、メッセージの構文を解析します。
そして、遅らせる時間、あるいは登録する絶対時間を検証して、その後actualSendMessageLoginに渡します。
この方法で予約投稿を実現できました。
文字色とスケジュールの構文追加ボタンの実装
Rocket.Chatでは押したら**でかこんで太字にしたり、_ _で囲んで斜体にしたりするボタンがもともとありました。
なので、それを真似て押したら<color=""({もともとあったテキスト})>
で囲むようにすればよいと考えました。
formattingButtonsというコンポーネントにこのような押したら選択されたテキストを加工する機能がありましたので、それにあたらしいボタンとして色の構文を追加するもの、スケジュール構文を追加するものを追加しました。
するとボタンを追加できました!
文字色選択ボタンの実装
一番難しかったのがこれでした。コマンドで色を指定するというのは非直観的なので、GUIで色を選択できるようにしたかったのですが、UIを整えることができませんでした。
mantineというreactのライブラリのカラーパレットを使おうとしたのですが、謎のエラーでmantineをインストールできなかったのが敗因でした。
なので、手作りの16色並べただけのカラーパレットを作りました。
import React, { useState, useCallback } from 'react';
import { ColorPicker, Text, Stack } from '@mantine/core';
import { ColorPickerContext } from '../contexts/ColorPickerContext';
export const ColorPickerProvider: React.FC = ({ children }) => {
const [isOpen, setIsOpen] = useState(false);
const [selectedColor, setSelectedColor] = useState<string>('rgba(47, 119, 150, 0.7)');
const [recentColors, setRecentColors] = useState<string[]>([]); // 追加: recentColorsのステート
const open = useCallback(() => {
setIsOpen(true);
}, []);
const close = useCallback(() => {
setIsOpen(false);
}, []);
return (
<ColorPickerContext.Provider value={{
open,
isOpen,
close,
selectedColor,
setSelectedColor, // selectColor から setSelectedColor への名前変更
recentColors, // 追加: recentColors
setRecentColors, // 追加: setRecentColors
}}>
{isOpen && (
<Stack align="center">
<ColorPicker format="rgba" value={selectedColor} onChange={setSelectedColor} />
<Text>{selectedColor}</Text>
</Stack>
)}
{children}
</ColorPickerContext.Provider>
);
};
このコンポーネントが押したら表示されるボタンを作り、色をえらぶとその色でコマンドが入力されるものができました。
ただ、絵文字を選ぶコンポーネント(既存)と違って
、overlapして表示されなかったので、それらの表示場所を司る場所の編集ができなかったのが今後の課題です。
英訳機能
チャットアプリにメッセージの翻訳機能があれば便利なのではないかと思い、実装して見ました。
翻訳するには
一般にメッセージを翻訳するためには外部の翻訳APIを取得する必要があります。
本実験ではGoogle翻訳のAPIいわゆる「Translation API」を用いようと考えました。
しかしお金が発生する可能性を考え、以下の記事を参考にGoogle Apps Scriptで無料の翻訳APIを作成しました。
Translation API
最初の 500,000 文字は無料 (2023年11月時点)
Webhook
RocketChatはWebhookのサービスがあり、メッセージなどをカスタマイズする機能がすでにあります。この機能を用いれば、ユーザが送ったメッセージを外部の翻訳APIに渡してそれの翻訳結果をRocketChatに返すことができます。
以下はWorkspaceの「統合」ページを表示しています。
ここで詳細設定を行い"Script"欄にコードを書くと、メッセージのカスタマイズが行えます。
例えばメッセージを入力するときに@english
というトリガーを書くと自分のメッセージが英訳できるようなコードを書くことができました。
しかしこれは実験の主旨である「大規模ソフトウェアを手探る」とは違うものです。なので、自分の送るメッセージではなく既に送られているメッセージの翻訳を実装することにしました。(これだとソースを手探る必要がある)
機能を実装するには
実装したい機能は既に送られているメッセージの翻訳です。これを実装するにあたってこの機能と似ている既存の機能を考えました。それは「メッセージの編集」「メッセージの返信」などです。
これらの機能は以下の画像のようにメッセージ横のバーのボタンで実行できます。
この場所に新たなボタンを追加して翻訳機能を実装することにしました。ただし翻訳機能は翻訳する言語を選択する必要がありますが、今回は英語に翻訳と固定することにしました。
ボタンの追加
既存のボタンの名前を参考にしながらこれらのボタンが定義されているファイルを詮索すると、Rocket.Chat/apps/meteor/app/uiutils/client/lib/messageActionDefault.ts
であることがわかりました。
ここに以下のようなコードを追加すると、ボタンを追加することができます。
MessageAction.addButton({
id: 'google-translation',
icon: 'google',
label: 'Translate',
context: ['message', 'message-mobile', 'threads', 'federated', 'videoconf', 'videoconf-threads'],
type: 'management',
async action(_, props) {
// ボタンがクリックされたときの処理をここに記述
},
condition({ message, subscription }) {
// messageとsubscriptionが存在する場合にボタンを表示
return Boolean(message && subscription);
},
order: 10, // ボタンの表示順序を指定
group: 'menu', // ボタンを表示するグループを指定
});
icon
とlabel
はapps/meteor/node_modules/@rocket.chat/icons/dist/index.d.ts
などで定義されているものから選ぶ必要があります。icon
に"google"があったので指定すると以下のようにボタンを追加することができました。
ボタンを押された時の処理
先ほど追加したボタンは形式上のもので押しても当然何も起きません。以下のようにボタンを押された時の処理を書きました
async action(_, props) {
try {
// propsからmessageを取得
const { message = messageArgs(this).msg} = props;
// メッセージの本文を取得
const msgText = getMainMessageText(message).msg;
// Google Apps ScriptウェブアプリのURLを設定
const scriptUrl = "GASで作った翻訳APIのURLを入れてください";
const sourceLanguage = "ja"; //元の言語(日本語)
const targetLanguage = "en"; //翻訳する言語(英語)
// Google Apps Scriptウェブアプリにメッセージを送信して翻訳を取得
const translationResponse = await fetch(`${scriptUrl}?text=${encodeURIComponent(msgText)}&source=${sourceLanguage}&target=${targetLanguage}`);
const translationData = await translationResponse.json();
if (translationData.code === 200) {
// 翻訳が成功した場合
const translatedMessage = translationData.text;
console.log(translatedMessage);
const modalContent = [
`元のメッセージ: ${msgText}`,
`Translated message: ${translatedMessage}`
].join('\n');
// 翻訳結果を表示するモーダルを開く
imperativeModal.open({
component: GenericModal,
props: {
title: '翻訳結果',
children: modalContent,
confirmText: 'OK',
onConfirm: () => {
imperativeModal.close();
},
onClose: () => {
imperativeModal.close();
},
},
});
} else {
console.error('Translation error:', translationData);
}
} catch (error) {
// エラーが発生した場合の処理
imperativeModal.open({
component: GenericModal,
props: {
title: 'エラー',
children: 'エラーが発生しました。',
confirmText: 'OK',
onConfirm: () => {
imperativeModal.close();
},
onClose: () => {
imperativeModal.close();
},
},
});
}
},
このようにメッセージを取得しそれを翻訳APIに渡して、元のメッセージと翻訳結果をモーダルで表示するようにしました。翻訳結果の表示方法ですが、理想はX(旧Twitter)やYoutubeのコメント欄の翻訳機能のような見やすい表示方法にしたかったです。しかし時間の都合上、簡単なモーダルウィンドウで元のメッセージと英訳結果を表示するようにしました。
自動返信提示機能
英訳機能の実装で外部のAPIを接続しメッセージに対して処理を行うことがわかったので、他のAPIを用いて実用的な便利な機能を追加しようと考えました。
前々から思っていたのですが、メールで「いつもお世話になっております。」や「何卒よろしくお願いいたします。」などと書くのがめんどくさくないですか?
そこで、Chat GPTのAPIを用いて自動で返信のテンプレートを表示してくれる機能を思いつきました。
機能を実装するには
機能を詳しく言うと、、、
相手から送られてきたメッセージに対して「自動返信」などのボタンを押すと、そのメッセージがChat GPTに渡されて、Chat GPTが考えた返信のテンプレートがテキスト入力欄に表示されるという機能を実装しようとしています。
入力は英訳機能と同じで「既に送られたメッセージ」で、出力が「APIからの返答」でこれも英訳機能の出力と同じです。ただ出力結果の表示を先ほどとは違いテキスト入力欄に表示する必要があります。
使用したOpenAI API
モデル : gpt-3.5-turbo
ボタンの追加
ボタンの追加は先ほどの英訳機能と同様で、Rocket.Chat/apps/meteor/app/uiutils/client/lib/messageActionDefault.ts
に追加します。
MessageAction.addButton({
id: 'auto-reply', //自動返信
icon: 'reply', //返信
label: 'Reply', //返信
context: ['message', 'message-mobile', 'threads', 'federated', 'videoconf', 'videoconf-threads'],
type: 'management',
async action(_, props) {
// ボタンがクリックされたときの処理をここに記述
},
condition({ message, subscription }) {
return Boolean(message && subscription);
},
order: 11,
group: 'menu',
});
先ほどと同様にicon
とlabel
はapps/meteor/node_modules/@rocket.chat/icons/dist/index.d.ts
などで定義されているものから選ぶ必要があります。なのでボタンの名前を「自動返信」などとすることができませんでした。
ボタンを押した時の処理
以下のようにボタンを押した時の処理を書きました。コードが長くなりなっているので、途中で分けて紹介します。
async action(_, props) {
try {
// propsからメッセージとチャットオブジェクトを取得
const { message = messageArgs(this).msg, chat } = props;
// メッセージの本文を取得
const msgText = getMainMessageText(message).msg;
// ユーザに対する質問を作成
const msgTextQuestion = `「${msgText}」に対する返信を考えてください.ただし返信結果のみを表示してください.鉤括弧は不要です`;
// .envファイルからAPIキーを読み込む
const apiKey = process.env.OPENAI_API_KEY;
// APIキーが存在するか確認
if (!apiKey) {
throw new Error('APIキーが見つかりません');
}
// OpenAI APIに送信するデータを準備
const requestData = {
model: "gpt-3.5-turbo",
messages: [
{ role: "user", content: msgTextQuestion },
],
};
ここまででしていることは、まず自動返信したいメッセージを取得します。そのメッセージから"msgTextQuestion"の「${msgText}」に対する返信を考えてください.ただし返信結果のみを表示してください.鉤括弧は不要です
にあるようにOpenAIのAPIに送るプロンプトを生成します。その後APIキー、URLそしてモデルを指定してOpenAI APIに送信するデータを準備します。
// OpenAI APIにPOSTリクエストを送信
const response = await fetch("https://api.openai.com/v1/chat/completions", {
method: "POST",
headers: {
"Content-Type": "application/json",
"Authorization": `Bearer ${apiKey}`,
},
body: JSON.stringify(requestData),
});
// レスポンスが成功であり、chatオブジェクトが存在する場合
if (response.ok && chat) {
// レスポンスデータからChatGPTの生成した返信を取得
const data = await response.json();
const reply = data.choices[0].message.content;
// チャットコンポーザにChatGPTの返信をセット
chat.composer?.setText(reply);
} else {
// ChatGPT APIエラー時の処理
console.error('ChatGPT API error:', response.statusText);
imperativeModal.open({
component: GenericModal,
props: {
title: 'エラー',
children: '返信予測の応答がありません。',
onClose: () => {
imperativeModal.close();
},
},
});
}
} catch (error) {
// その他のエラー時の処理
console.error('Error:', error);
imperativeModal.open({
component: GenericModal,
props: {
title: 'エラー',
children: 'APIリクエスト中にエラーが発生しました。',
onClose: () => {
imperativeModal.close();
},
},
});
}
},
先ほど作ったOpenAI APIに送信するデータにPOSTリクエストをし、レスポンスがあれば結果を表示します。英訳機能では翻訳結果をモーダルウィンドウで表示しましたが、今回はchat.composer?.setText(reply);
というようにチャットコンポーザにChatGPTの返信をセットしています。これによってユーザはChatGPTが考えてくれた返信のテンプレートを編集して送信することができます。
あとはレスポンスがない時とその他の場合についてモーダルでエラーを表示するようにコードを書きました。
デモ
下の画像のように返信するのがめんどくさいメッセージが送られてきました。
このようにメッセージ入力欄に予測返信が表示されます!(かなり便利な機能だと思います。)
添削機能
自動返信機能では他の人が送ったメッセージをAPIに渡すことで機能を実現しました。そこで今度は自分がこれから送ろうとしているメッセージをAPIに渡すことで自分のメッセージを添削してくれる機能を考えました。
機能を実装するには
今回は入力が「自分が入力したメッセージ」でそれを外部APIが処理し出力結果をメッセージの入力欄に表示させます。ロジックはこれまでの機能とほとんど同じです。
ただ今までと異なり「自分が入力したメッセージ」を入力とするため、ボタンの追加箇所を変える必要があります。
ボタンの追加
添削機能のボタンはメッセージの入力欄の下の絵文字やマークダウンなどのところに配置すればいいと考えました。そのボタンを定義しているファイルを詮索すると/apps/meteor/client/views/room/composer/messageBox/MessageBox.tsx
であることがわかりました。
以下のようにボタンを追加しました。
<MessageComposerAction
icon='edit'
onClick={async () => {
try {
// チャットコンポーザからテキストを取得。存在しない場合は空文字列
const textToProcess = chat.composer?.text ?? '';
// ChatGPTにテキストを送信して処理を行う関数を呼び出し
const response = await callChatGPT(textToProcess);
if (response) {
// チャットコンポーザにChatGPTの応答を設定
chat.composer?.setText(response);
}
} catch (error) {
//エラー処理
console.error('ChatGPT API リクエストエラー:', error);
}
}}
/>
「チャットコンポーザからテキストを取得」すなわち「入力中のメッセージ」を取得し、callChatGPT
関数に入力しレスポンスがあれば「チャットコンポーザに取得」すなわち「入力中のメッセージを変更」するようにしました。
またicon
はedit
(鉛筆マーク)としました。絵文字のボタンの定義されている部分の下にこのコードを入れたので以下の画像のように絵文字マークの横にボタンが追加されました。
callChatGPT関数
ボタンを押した際に呼び出される関数で自動返信機能の時の関数とほとんど同じです。
異なる部分はChat GPTに送信するプロンプトが今回だと 「 ${text} 」を推敲してください。ただし推敲された文章のみ(「」も表示しない)を表示してください.
としています。
async function callChatGPT(text: string) {
// .envファイルからAPIキーを読み込む
const apiKey = process.env.OPENAI_API_KEY;
// ChatGPTに送信する質問文を作成
const msgTextQuestion = `「 ${text} 」を推敲してください。ただし推敲された文章のみ(「」も表示しない)を表示してください.`;
// ChatGPTに送信するデータを準備
const requestData = {
model: 'gpt-3.5-turbo',
messages: [{ role: 'user', content: msgTextQuestion }],
};
// ChatGPT APIにPOSTリクエストを送信
const response = await fetch('https://api.openai.com/v1/chat/completions', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${apiKey}`,
},
body: JSON.stringify(requestData),
});
// レスポンスが成功の場合
if (response.ok) {
// レスポンスデータからChatGPTの生成した応答を取得
const data = await response.json();
const reply = data.choices[0].message.content;
return reply; // ChatGPTの応答を返す
}
// ChatGPT APIリクエストが失敗した場合はエラーをスロー
throw new Error('ChatGPT API リクエストが失敗しました');
}
デモ
以下の画像のように少し荒い日本語のメッセージを書いたとしましょう。
そこで先ほど追加したボタンを押すと、、、
なんと綺麗な日本語になりました!
感想
大規模なOSSを触るのが班員全員初めての経験で、最初はどの機能をどのコードが担当しているのかを見極めるのに苦労しました。機能自体よりもUIを変更する方が難しい場合もあり、意外性を感じました。特に外部のAPIを叩くのが予想以上に簡単で、普段からあったらいいなと感じていた機能を実装することができてよかったです。