🗂

Slack Hosted Functions + Slack DatastoreでCSVをエクスポート/インポートする時のハマりどころ

に公開

前書き

Slack 次世代プラットフォーム(Deno Runtime)で開発中のアプリケーションに、Slack Datastore へ登録されたマスタ情報を CSVファイル で入出力する機能を追加していたところ、文字コード関連の問題が発生しました。利用者の多くが Windows + Excel でCSVファイルの編集を行う環境なので、UTF-8 でエクスポートを行うと Excel で開いた際に文字化けが起こります。そこで、Excel でも問題なく開けるよう BOM の付与を行ったところ、「アプリからSlack へアップロード → ユーザーがダウンロード」する過程でSlackに削除されてしまう──そんな状況です。

結論から言うと、以下の方針で落ち着きました。

  • エクスポート(ユーザーがマスタをダウンロードするフロー)
    • CSV 生成時に UTF-8 + BOM を付与する
    • Slack の自動再エンコードを避けるため、JSZip で ZIP に包んでからアップロードする
  • インポート(ユーザーが更新済み CSV をアップロードするフロー)
    • files.info(...).content を文字列として取得し、既存バリデーションに通す(BOM は既に除去されている前提)

以下は Hosted Functions の範囲で完結させたメモです。Bolt + 自前サーバーならもっと選択肢はありますが、ここでは Deno Runtime 限定で話を進めます。

Slack アプリで CSV ファイル を出力するときに押さえておく仕様

ここから数節は エクスポート(ダウンロード)処理 を中心に書いています。まず前提となるのが Slack のファイル API(次世代プラットフォームでも呼び出し方は同じ)です。

  • files.upload はシンプルですが、送信するデータを Slack 側で再エンコードする挙動がある(実測値)ため、BOM を保持したい用途とは相性が悪い
  • files.getUploadURLExternal → HTTP POST(自前の fetch)→ files.completeUploadExternal の 2 段階アップロードは、こちらで用意したバイト列をそのまま保存できる仕組みとして公式に案内されている
  • files.infocontent を取得すればファイル本文を文字列として読める(BOM は除去済み)ので、Bot Token なしでも取り込みだけなら実装できる

つまり「UTF-8 + BOM を付けて配布したい」と思っても、files.upload 経由だと勝手に削られてしまいます。Shift-JIS で配る案も試しましたが、Slack から private file を fetch するには Bot Token が必要で、Hosted Functions だけの構成では扱いにくい、というのが実情でした。

そこで採用したのが ZIP で包んで配布する という方法です。

ちなみに他にもいくつか試しています。

  • BOM を 2 個付ける案: Slack はどちらも削除しないため Excel では化けないが、利用者が「編集せずにそのまま再アップロード」を行った場合 BOM が二重のままになり、Slack UI がスニペット表示を行わず files.info(...).content で本文が取得できない(= Bot Token を使って直接ダウンロードする必要が出てしまう)
  • Shift_JIS で配布する案: エクスポートは問題ないものの、インポート時に fetch(file.url_private_download) で生バイトを取得する必要があり、Hosted Functions では Bot Token を仕込めない環境があるため断念

ZIP で配布する方針

やっていることは次の通りです。

  1. Datastore から取得したデータを基に CSV 文字列を生成する
  2. 先頭に \uFEFF を付与して UTF-8 + BOM の形式にそろえる
    const addBom = (csv: string) => csv.startsWith("\uFEFF") ? csv : `\uFEFF${csv}`;
    
  3. TextEncoder でバイト列へ変換し、JSZip で ZIP にまとめる
    const zip = new JSZip();
    zip.file(filename, csvBytes);
    const zipBytes = await zip.generateAsync({ type: "uint8array", compression: "DEFLATE" });
    
  4. files.getUploadURLExternal で一時 URL を取得 → fetch(upload_url, { body: zipBytes })completeUploadExternal

ZIP にすることで Slack が中身を触れられなくなり、BOM が削られる問題を回避できました(hexdumpEF BB BF が残っていることも確認)。利用者には DM のメッセージで「ZIP を解凍してから CSV を開いてください」とひと言添えておきます。

Slack 側では ZIP を展開せず、そのままユーザーの DM に添付してくれるので、文字コードを保ったまま配布できます。

ここまではダウンロード(エクスポート)側の話でした。次はアップロード(インポート)時の注意点です。

CSV 取り込み(インポート)時のバリデーション

取り込み側は client.files.info(...).content で取得したテキストをそのまま取り扱います。Slack から受け取る文字列は BOM が除去されていますが、ヘッダーやフィールド数をチェックするパーサで弾いているため、UTF-8 前提で問題ありません。

主なチェックはこんな感じです。

  • 先頭行が想定した CSV ヘッダー(例: column1,column2,...)になっているか
  • 1 行あたり 4 列であること
  • 必須列(ID、名前、カテゴリ、フラグなど)が空でないこと
  • フラグ値が 1 or 0 のみであること
  • 制御文字を除去した上で、アプリ側のエンティティとして再構築すること

つまり、ZIP 化はダウンロード側だけの工夫 で、取り込み側は特別な対応をしなくても動きます。

運用上のポイント

  • DM やモーダルのヒント文に「ZIP を解凍してから編集する」旨を明記しておく
  • Slack のプレビューはスニペット扱いで BOM が見えないが、実際にダウンロードして hexdump すれば EF BB BF が確認できる
  • ZIP にしておくと、将来テンプレートや README を同梱するなどの拡張にも使える

まとめ

Slack のファイル API は便利な一方で、テキストファイルに対して勝手にエンコードを調整してしまう仕様があります。今回のように文字コードを崩したくないケースでは、ZIP にするのが一番シンプルでした。

今回のポイントは以下の通りです。

  • Slack へ CSV を直接アップロードすると BOM が剥がされる
  • files.getUploadURLExternal で ZIP を渡せば中身は触られない
  • 取り込み側は files.info(...).content で従来どおり処理できる

同じような課題にハマっている人の参考になればうれしいです。

リバナレテックブログ

Discussion