Slackが今年の9月から無料プランだと90日より前のメッセージが見れなくなってしまう、ということで 過去のメッセージをバックアップするシステムをGoogle Apps Scriptを使って作りました。
- Slackの個人的な使い方
- Google Apps Scriptでやる訳
- Slack-gas
- 定期的な実行
- Slack-gasのパラメーター設定
- その他作ってるときに気づいたことやよくわからなかったこと
- まとめ
Slackの個人的な使い方
バックアップを取るにしても使い方によって欲しいものが違うと思いますが、個人的には ちょっとしたメモ代わりに使っている感じです。
ちゃんとしたメモはEvernoteに。
あとは適当にbotとかにつぶやかせてるものもあったりします。
自分で管理しているのは個人のWorkspaceだけで、無料版で人間ユーザーは自分だけ、という状態です。
なので過去のメッセージが消されてしまってもそれほど問題ないのですが、 たまにちゃんとメモに残してなかったものをSlackから探したりすることもあるので残ってたら便利だな、程度のもの。
Google Apps Scriptでやる訳
お金払ってやるなら単にSlackに課金すれば良いので、無料で出来る環境を作ります。
無料で定期的にタスクを実行できる環境、となるとHerokuの無料枠とか今ならGitHub Actionsとかも便利だったりしますが、 Google Apps Scriptも無料で定期的なタスクの実行が可能です。
また、保存先としても、個人的なメインクラウドストレージがGoogle Driveなのでそれに繋げやすいというメリットもあります。
難しい点としてはGoogle Apps Scriptの1つのジョブの実行時間には6分の時間制限があるので Slackに大量のメッセージがある場合は処理が一回では終わらない可能性があります。
下にまた書きますが、自分の使い方だとそれほどメッセージの量がないので(数千程度)何回か手動で実行すれば全部詳しいメッセージ情報が取れたのでこれで良いか、という感じですが、 大量にあるとGoogle Apps Scriptだけでは取り切るのが辛いので別の環境でちょっと改造してNodeでやるとか、Pythonで書き直して作るとかして、少なくとも最初のバックアップは別の環境で行う必要が出てくるかもしれません。
GASでSlackのバックアップを取る、といったものもいくつか見当たりましたが、 古くて使えなかったり、微妙に動かなかったりしたので、 自分の欲しい情報を保存出来るように作ってみました。
Slack-gas
ということで以下のようなスクリプトを作りました。
使い方としては、まずSlack側:
- Slack APIのページから
- Create New AppFrom Scratchで新しいアプリを作成。
- OAuth & Permissionsに行って、下の方にあるScopesでスコープを追加。
- 以下のUser Token Scopesを追加:
channels:history
,channels:read
: パブリックチャンネルを読む場合groups:history
,groups:read
: プライベートチャンネルを読む場合im:history
,im:read
: ダイレクトメッセージを読む場合mpim:history
,mpim:read
: 複数人の入ったダイレクトメッセージを読む場合users:read
: ユーザーリストの取得のためfiles:read
: ファイルをダウンロードするため
- もしプライベートチャンネルやダイレクトメッセージは必要ない、ということであれば
group
やim
,mpim
などは加えなくてもOK。
- 以下のUser Token Scopesを追加:
- OAuth & Permissionsの上の方で、Install to Workspaceを実行して許可。
- User OAuth Tokenをコピー。
追記: 2022/08/29
追記で加えましたがファイルを読み込むためにfiles:read
が必要です。
追記ここまで
User Token Scopesの代わりにBot Token Scopesで同様にスコープを追加して、Bot OAuth Tokenを使ってパブリック、プライベートチャンネルを読むことも出来ますが、BotにするとそのBotを必要なチャンネルに招待しないとメッセージが読めないので、個人用であればUser Token Scopesを使ってやるほうが楽です。
また、もし複数人が使っているチャンネルですべてのDMとかも含めてバックアップしたい時はadmin
系のScopeを使って行う必要があります。
次に、Google Apps ScriptをGoogle Sheetsの機能拡張として用意:
- Google Driveに適当なフォルダを作り、その中にGoogle Sheetsを作る。
- Google Sheetsの機能拡張Apps ScriptでApps Scriptを起動。
- main.gs、params.gs、secrets.gsの3つのスクリプトファイルを作り、上のレポジトリの各ファイルの内容をコピー。
- secrets.gsの
<YOUR_SLACK_TOKE>
を上で取得したUser OAuth Tokenに置き換える。
以上でとりあえずの準備完了。
後はparams.gsの中身を良しなに変更して実行します。
実行は、main.gsを表示した状態でrun
関数を選んで実行ボタンを押してください。
これで、
みたいなチャンネルのメッセージがあるとすると
みたいな感じでGoogle Sheetsに記録されます。
(ここではSAVE_MESSAGE_JSON=true
に設定しています。)
添付ファイルがある場合、Files: ...
と言う形でファイル名にリンクが付いた状態で表示されます。
このファイルはGoogle Driveの中のこのGoogle Sheetsがあるフォルダー内の<channel name>/files
フォルダーの中に保存されています。
また、SAVE_MESSAGE_JSON=true
とすると、APIでとってきた元の情報をJSON形式でDriveに保存するようになり、
UnixTime
カラムのメッセージのUnixTimeがリンクになり、そのリンク先がJSONファイルになります。
このファイルはGoogle Driveの中のこのGoogle Sheetsがあるフォルダー内の<channel name>/messages
フォルダーの中に保存されています。
スレッドがある場合はThreadTS
カラムにそのスレッドが開始された時間(UnixTime)が記録され、それが同じものが同一スレッド内にあることが分かります。(UnixTime
カラムが一番古いものがその親メッセージ。)
もしメッセージが編集されると、Edited
カラムに編集時間が記録されます。
古いメッセージも編集前に取得されていれば記録は残ります。
定期的な実行
動くことが確認でき、一定の初期取得が終了したら後は毎日スクリプトを走らせて自動で記録しておくようにします。
Apps Scriptの時計ボタン(トリガー)へ行き、トリガーを追加から
- 実行する関数を選択:
run
- 実行するデプロイを選択j:
Head
- イベントのソースを選択:
時間主導型
- 時間ベースのトリガーのタイプを選択:
日付ベースのタイマー
- 時刻を選択:
午前0時~1時
(GMT_09:00) - エラー通知設定:
毎日通知を受け取る
と言った感じでトリガーを追加します。
これで毎日深夜にSlackをSheetsにバックアップします。
Slack-gasのパラメーター設定
params.gsの中で設定できるパラメーターがいくつかありますが、特に重要な項目として以下のものを考慮していただければ、と。
TIME_ZONE
デフォルトではnull
になっていて、そのままだとApps Scriptで設定されたタイムゾーンが使われます。
(Apps Scriptの歯車マーク(プロジェクの設定)内で設定されているもの。)
日本で使っていてもこれが日本標準時になっていない場合もあるので、とりあえず日本なら
const TIME_ZONE = 'Asia/Tokyo';
と設定しておくと必ず日本時間になおして時刻を表示してくれるようになります。
SAVE_MESSAGE_JSON
SAVE_MESSAGE_JSON = true
とすると元の情報が残るのでこのスクリプトによって十分情報が取り出せてない場合に後から確認できて便利です。
ただ、これをtrue
にしてしまうと実行にかなり時間がかかるようになり、6分の時間制限だと多くて100くらいのメッセージしか取れません。
なので、少なくとも最初の実行では、よほどのことが無い限りSAVE_MESSAGE_JSON = false
で実行することをお勧めします。
Slackのエクスポート機能があるので、現時点でのすべての記録はこれで一回取っておけばよいかと。
毎日の投稿が数十位であればSAVE_MESSAGE_JSON = true
にしてトリガージョブを実行しても良いかと思います。
FULL_CHECK/COVERAGE/CHECK_THREAD_TS_IN_SHEET/THREAD_TS_COVERAGE
FULL_CHECK = false
とすると、各チャンネルの最後に記録されたものより後のメッセージだけを確認するようになります。
ここでちょっと注意が必要で、このチェックはスレッド内のメッセージとは別なので、 もし、スレッドの最初のメッセージがチェックされないと、スレッド内に新しいメッセージがあったとしても記録されません。
なのでスレッドをよく使う場合はFULL_CHECK = true
にすることをお勧めします。
よほど大量のメッセージがある場合を除いて
true
にしてもそれほど大きく実行時間は変わりません。
(現時点で過去何年分ものメッセージが大量にある場合は最初の実行以降、false
にした方が良いかもしれません。)
また、COVERAGE
を使ってメッセージを過去どこまで遡って見るか、を設定することもできます。
これは秒数なので2592000
(60*60*24*30
)なら約1ヶ月分だけをチェックすることになります。
1年なら31536000
(閏年を含むなら31622400
)。
もし現時点で過去何年分もの大量のメッセージがあって、そこまで昔のものは必要ない(もしくはエクスポートしたものだけでとりあえずは良い)という場合は適当な時間を設定してください。
FULL_CHECK = true
としてもこのCOVERAGE
の時間までしか遡って取得しません。
もし90日以上続くような長いスレッドがあるような場合は、無料で90日制限がかかると そもそもメッセージが取れないので、スレッドが残っていてもそのスレッドの新しいメッセージを取得できなくなります。
この場合、スレッドごと消えてしまうんでしょうか?それとも古いものは削除された形で見れる?
仮にスレッド自体は残る場合、
CHECK_THREAD_TS_IN_SHEET = ture
にしておくと、すでに取得したメッセージが取得時点でスレッドを持っていれば
そのスレッドをチェックする様にしてあるのでスレッド内の新しいメッセージも取得できるようになります。
もし、90日以上続くような長いスレッドを作るような場合は
CHECK_THREAD_TS_IN_SHEET = ture
にして、確認するスレッドの期限をTHREAD_TS_COVERAGE = 31536000
みたいな感じで1年位にしておくとかにすると良いかと思います。
その他作ってるときに気づいたことやよくわからなかったこと
以下は作ってるときに気づいたことやよくわからなかったことなので、自分で作ってみようと思う人は参考にしていただければ、と。
Slack APIのconversations
中心への移行
2018年なので結構前ですが、それまではプライベート、パブリックチャンネルなど、それぞれでAPIのメソッドが違ったものを、conversations
というメソッドで統一してパラメーターでスコープを変更できるようになりました。
The Conversations API is required to work with channels consistently Slack
この辺、検索すると未だに古いメソッドを使っているものも結構あるので
SlackのAPI methodsページなどを確認して
Deprecated
なものは使わず新しいものを使うように。
Bot OAuth Token
大概のSlack Appの紹介だとBot OAuth Tokenを使った方法が紹介されていますが、 上にも書いたようにこれだと作ったApp(Bot)を各チャンネルに招待しないと、 APIが
{error=not_in_channel, ok=false}
といったエラーを返します。
個人用のWorkspaceであれば個人権限で見れるところを全部見て問題ないので 今回の用法だとUser OAuth Tokenを使った方が楽です。
ScopesでUser Token Scopesだけを設定してもBot OAuth Tokenも発行されるので、 そちらをコピーしてしまうと何も見れないので注意。
Scopesの削除は出来ない?
追記: 2022/08/29
SlackのAPI設定のScopesのところで、一度あるScopeを加えて(Re)Install to Workspaceした後、 そのScopeを外して再度Reinstall to Workspaceをしても外したScopeの権限も持っている様に振る舞います。
一度許可してしまった権限を狭めるためには新たなAppを作らないと駄目?
追記ここまで
スレッドの取得
SlackのAPIではconversations.history というメソッドを使ってメッセージ一覧を取得できますが、 スレッド内のメッセージに関しては conversations.replies という別のメソッドで取得する必要があります。
このconversations.replies
にはそのスレッドがあるチャンネルのIDのスレッドの開始時刻(最初のメッセージのタイムスタンプ)が入力として必要になります。
チャンネル内の全てのメッセージのts
(タイムスタンプ)の値を取得しておいて、後からreplies
でチェックする事もできますが、
スレッドがあるメッセージにはthread_ts
(スレッドの最初のメッセージではts = thread_ts
)という値があるためこれ
があるものだけを取得してスレッドを取得しに行った方が効率的です。
過去のメッセージについたスレッドを探すような場合でも、事前にとっておいたts
に対して全てconversations.replies
で試すより
conversations.replies
でもう一度全てのメッセージを取得してthread_ts
を抜き出してからそれらにreplies
をかけたほうが効率的です。
1つだけ、90日のメッセージ保持期限ぎりぎりの元メッセージがあり、それに対して新しいスレッドの返信がついたとすると、
thread_ts
が取得できないのでスレッドの新しい返信も取得できなくなります。
この場合は記録されたts
の値を使えばスレッド内の新しいメッセージを取得できる可能性はありますが、
レアケースなのでとりあえずこのような状況は無視しています。
メッセージ取得時点ですでにスレッドがあって、そのスレッドの親メッセージが90日より前になってしまっても、
上に書いたように
CHECK_THREAD_TS_IN_SHEET = ture
としておけばSheetに記録されたthread_ts
を使ってスレッドをチェックできるようになっています。
(ただ、Slackの仕様で仮にスレッドの親メッセージが古いとスレッドごと見れなくなる状態だとすると取得はできないかもしれません。)
User情報
通常のユーザーが書いたメッセージであれば
user
という値があり、ユーザーのIDが入っていて、
users.listメソッドを使って別途取ってきたid
とname
の対応を使って
ユーザー名を取得することができます。
Botが書いたものだと、代わりにbot_id
という値が入っていて、これを使ってbot名を調べることが可能なのですが、
bots.list
みたいなメソッドは用意されていません。
代わりに
bots.info
というメソッドがあって、これに対してbot_id
を与えてあげるとそのbotの情報をくれてname
で名前を調べられます。
また、Botとは別に、Appによるメッセージ、というものもあって、これだとuser
やbot_id
はなく、
代わりにusername
という値でAppの名前が入っています。
なんか全部にusername
を入れてくれれば良いのにと思ったり。。。
message情報内のtext
APIで取得した各メッセージの情報内にはtext
という値があって、この中にメッセージテキストが入っています。
Botの場合でも同様。
ただ、Appによる場合は別で、text: ''
な状態で、一方でattachments
という値が入っています。
このattachments
の中にtext
という値があり、ここにAppによって書かれたメッセージが入っています。
実体のないfilesの情報
追記: 2022/08/29
メッセージに添付ファイルがある場合、APIで取得した各メッセージの情報内にはfiles
という値があって
その中にダウンロード用のURLなどがありそれを使って添付ファイルをダウンロードすることが出来ます。
最初にやっていたときにはこのfiles
は添付ファイル1つに対して1つ存在し、それらは必ずurl_private_download
という情報を持っていました。
が、その後使ってくれた人からも指摘して頂きましたが、自分で再度1つチャンネルを一度消して再取得してみると
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
|
みたいな感じで、url_private_download
だけでなく、name
なども無いid
とmode
だけのものが存在していました。
mode
がtombstone
ということなので削除されたファイルのid
だけ記録として残している遺物の様です。
実際ファイルを削除したメッセージを読んでみるとこうなりましたが、
最初に自分の環境で出てきたメッセージでは特にファイルを削除したわけではなくもとからファイルが付いていたもので以前はtombstone
はなかったはずなのですが。
現状メッセージを見てもファイルが削除されたとはなってないのですが、何か変更を加えたのかもしれません。
もしくは、Slack側の事情でtombstone
が生まれる事があるのかもしれません。
いずれにしろこれがあるとエラーで止まってしまう状況だったので
tombstone
の場合にはskipするようにしました。
恐らくそれ以外はurl_private_download
がちゃんとあるとは思うのですが、
もしない場合にはwarningを出してskipするようにもしてあります。
追記ここまで
ダウンロードファイルのBlob
通常のサイトから何かファイルをダウンロードしてGoogle Driveに保存したい、となると、 Apps Scriptでは
const folder = <drive folder>;
const fileName = <fileName>;
const url = <url/of/file>;
const response = UrlFetchApp.fetch(url);
let blob = response.getBlob();
blob = blob.setName(fileName);
const file = folder.createFile(blob);
みたいに、fetch
したものからblob
を取得してcreateFile
に与えてファイルを作ります。
こで普通にインターネットにあるファイルは保存出来るのですが、Slackの添付ファイルをこれで保存しようと思うとうまくいきません。
メッセージ情報の中にあるurl_private_download
という情報を使うので、まず、
const response = UrlFetchApp.fetch(url, {headers: Authorization: 'Bearer <Token>'});
の様にTokenを渡さないとアクセスエラーになりますが、これで取得したものに対してblob
を取得してファイルを作ると中身はHTMLみたいな内容になっています。
blobを使わず、
const folder = <drive folder>;
const url = <url/of/file>;
const response = UrlFetchApp.fetch(url, {headers: Authorization: 'Bearer <Token>'});
const file = folder.createFile(response);
とすれば上手く行きます。ファイル名も添付ファイルの名前がそのまま使われるのでこれだけでOK。
メッセージの中に情報として、url_private_download
の他にurl_privaet
というURLもありますが、どちらを使っても同じでした。
追記: 2022/08/29
これも再度試してみたところ、blob
の方法を使ってもちゃんとファイルが書き込めていました。
HTMLみたいなものが取得されていたのはScopeの問題(files:read
が必要)だった可能性があります。
色々いじってるときにfiles:read
を加えて試して、改めて削除して試して、みたいなことをしていたのですが、
上にも書いたようにScopeを外してもその権限が残っている様なのでそこで勘違いしていた様です。
createFile
に関しては、blob
を取り出して渡してもfetch
した内容をそのまま渡しても同じ内容のものを作ってくれるようで、
恐らく中でうまいこと処理してくれている模様。
追記ここまで
Google DriveのFolderのremoveFileメソッドの罠
Apps Scirptから使えるGoogle DriveのAPIでFolderオブジェクトのremoveFileというメソッドがあります。
このメソッドは現在はDeprecatedなものになっていますがまだ使えます。
名前からするとフォルダの中から指定したファイルを削除する(ゴミ箱に入れるか完全削除)ものに思えますが、 実際にはフォルダの中からは消し、Driveのトップフォルダに移す、というものになっています。
移行先のメソッドがFile.moveTo
なのでまさにそういう意図で作られたメソッドなのですが、
ファイルの削除だと思ってこれを使っているとDriveのトップフォルダにゴミが溜まっていくので注意。
実際にファイルを削除(というかゴミ箱に移動)したい場合には File.setTrashedを使います。
この辺はブラウザで操作しているとちょっと直感と合わないですが、 実際に裏でファイルをどの様に扱っているか、ということを考えればまあ分かる、といったエラーを返します。
いずれにしろ、removeFile
という名前は勘違いしやすいので気をつけておいた方が良いです。
上のsetTrashed
というメソッドに関しては、ドライブがチーム用のものだったりすると
ちょっとそのままでは上手く行かないこともあるようです。
Cannot remove a file from Team Drive with Apps Script - Stack Overflow
Sheetsでの桁落ち問題
Apps Scriptの中でSheetsに関するAPIを使って書き込む際、UnixTimeの書き込みで桁落ちが発生して、 後から比較する際に使えない状態になってしまいました。
この辺、文字列でも数字っぽいと数字と勝手に変換して色々と良さげに保存してくれたりするんですがそれが問題に。
これに関しては
sheet.getRange(x, y, x_length, y_length).setNumberFormat("@").setValues(data);
の様に、取得したCell(Range)に対してsetNumberFormat("@")
を実行してやるとそのまま記録してくれるようになり、問題を解決できました。
google sheets - Script ‘setValues’ method interprets strings as numbers in cells - Stack Overflow
Sheets内にリンクを書き込みたい
Sheetsのセル内にリンクを表示するためにはnewRichTextValue
を作ってsetValues
の代わりにsetRichTextValues
を使って書き込む必要があります。
const url = 'https://example.com';
const text = "Set Link";
const richText = SpreadsheetApp.newRichTextValue().setText(test)
.setLinkUrl(4, 8, url)
.build();
の様にしてRichTextを準備します。文字列を用意したら、その中でリンク化したい部分を指定してsetLinkUrl
します。
(最初の引数が0スタートの文字の位置(最初の文字が0番目)、2つ目が終わり次の文字の位置。)
setLinkUrl
は複数指定可能です。
最後にbuild()
を実行するのを忘れないように。
これで作ったRichTextをSheetのCellやRangeのAPIを使ってsetRichTextValue(s)
で書き込んであげると上の例の様にリンクが作れます。
Range.setRichTextValuesでは全ての入力がRichTextValueである必要があり、、データを書き込む際、一つ一つ書き込むより一気に書き込んだ方が早いので、 全てのデータをRichTextに変換して書き込んでいます。
リンクを書き込んだ次の行がリンクじゃないのにリンクっぽくなってしまう問題
Range.setRichTextValues
を使ってRichTextを書き込んだとき、
あるセルにリンクだけ:
const url = 'https://example.com';
const text = "Link";
const richText = SpreadsheetApp.newRichTextValue().setText(test)
.setLinkUrl(0, 4, url)
.build();
みたいなものを書き込むと、その次の行に書き込んだ下のセルが、リンクでない部分もすべて青文字下線状態になってしまいます。
これは、Range.setRichTextValues
で一気に書き込む場合には起こらず、
改めてRange.setRichTextValues
したりCell.setRichValue
する際、その直上のセルがリンクだけだと、
その列の以下の全てのセルが青文字下線状態になる状態です。
リンク部分は一応ちゃんとリンクとして機能はしているものの、それ以外も青くなるので実際にリンクなのかどうかよく分からなくなってしまいます。
最初、メッセージのテキスト部分がなく、ファイルだけが添付されたメッセージに関してはリンク付きファイル名だけを記入してましたが これだと問題が起こるので、必ず
Files: aaa.txt
のようにリンクにならないFiles:
の部分を用意してやることでこの問題は回避しています。
これに関しては自分のコードがバグってる可能性もあるんですが、色々試してもなんともならないので GASのGoogle SheetsのAPIのバグなんじゃないかな、と思ってます。
今のところそれっぽいものはIssueいはないですが: status:open componentid:191640+ type:bug - Issue Tracker
まとめ
Slackのメッセージが無料だと過去90日までにしか見れなくなる、というで、 一応メモ代わりに使ってきたものとして消えちゃうと後で困るかもしれないので バックアップを取れるようにしました。
自分の使い方的にそれほど重要なこともないので、エクスポート機能で時々とればよいか、ということもあるんですが、 パット確認出来るようにする、ということと、定期的にバックアップを実行するために、 Google Apps Script (GAS) + Google Sheets + Google Driveの形でバックアップのシステムを作りました。
添付ファイルなども取れるので、いい感じ。
ただ、GASでやると時間制限がきつくて、今までの制限の1万件近くメッセージがある場合や、 毎日千件以上の新しいメッセージが作られるような場合はちょっと辛いかもしれません。
そういった場合にはちょっとNode.jsを使って書き直すか、Pythonとかで一から書き直すかして 別の場所で動かす必要があるかもしれません。
Google Sheets + Google Driveは便利なので、とりあえず一回全部バックアップする分をそれらでやって、 後はGASで毎日追加分を入れる、というのが一番良い感じはします。
自分の使い方だとSAVE_MESSAGE_JSON = true
にして1回では全部取れませんでしたが、何回か手動で実行すれば
取得できるレベルで、毎日の追加は10件もないような状態なのでGASだけでなんとかなったので楽できて良かったです。