rcmdnk's blog
Last update

Slack

Slackが今年の9月から無料プランだと90日より前のメッセージが見れなくなってしまう、ということで 過去のメッセージをバックアップするシステムをGoogle Apps Scriptを使って作りました。

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: ファイルをダウンロードするため
      • もしプライベートチャンネルやダイレクトメッセージは必要ない、ということであればgroupim, mpimなどは加えなくてもOK。
    • 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.gsparams.gssecrets.gsの3つのスクリプトファイルを作り、上のレポジトリの各ファイルの内容をコピー。
  • secrets.gs<YOUR_SLACK_TOKE>を上で取得したUser OAuth Tokenに置き換える。

以上でとりあえずの準備完了。

後はparams.gsの中身を良しなに変更して実行します。

実行は、main.gsを表示した状態でrun関数を選んで実行ボタンを押してください。

これで、

20220811_slack.png

みたいなチャンネルのメッセージがあるとすると

20220811_sheets.png

みたいな感じで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メソッドを使って別途取ってきたidnameの対応を使って ユーザー名を取得することができます。

Botが書いたものだと、代わりにbot_idという値が入っていて、これを使ってbot名を調べることが可能なのですが、 bots.listみたいなメソッドは用意されていません。

代わりに bots.info というメソッドがあって、これに対してbot_idを与えてあげるとそのbotの情報をくれてnameで名前を調べられます。

また、Botとは別に、Appによるメッセージ、というものもあって、これだとuserbot_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つチャンネルを一度消して再取得してみると

message
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
{ type: 'message',
  text: 'Attachments:',
  files:
   [ { id: 'XXXXXXXXXXX, mode: 'tombstone' },
     { id: 'YYYYYYYYYYY',
       name: 'abc.txt',
       title: 'abc.txt',
       mimetype: 'text/plain',
       filetype: 'text',
       pretty_type: 'プレーンテキスト',
       ...
       mode: 'snippet',
       ...
       url_private: 'https://files.slack.com/files-pri/XXXXXXXXXXXXXXXXX/abc.txt',
       url_private_download: 'https://files.slack.com/files-pri/XXXXXXXXXXXXXXXXX/download/abc.txt',

みたいな感じで、url_private_downloadだけでなく、nameなども無いidmodeだけのものが存在していました。

modetombstoneということなので削除されたファイルの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だけでなんとかなったので楽できて良かったです。

Sponsored Links
Sponsored Links

« MathJaxを使って数式をWebページに表示する デスクモニターアーム エルゴトロンMXV »

}