承認されたら Twitter 投稿 - 広報担当者必見の kintone カスタマイズ -

著者名:竹内 能彦(サイボウズ)

目次

はじめに

皆さん、Twitter を使っていますか?
私は最近始めました!

企業で Twitter を利用する場合は承認機能やツイート日時の指定機能が欲しいですよね。
今回は kintone と AWS Lambda を使って、kintone に登録された内容を Twitter へ投稿するカスタマイズを紹介します!

運用イメージ

  1. 社員が Twitter に投稿する内容、投稿したい日時を kintone に登録
  2. 上司が内容を確認して、承認/却下
  3. 承認済かつ投稿したい日時が過去のデータをツイート(画像付きツイート、公式引用ツイートが可能)

投稿日時の指定機能のために AWS Lambda を利用します。
また、Lambda 関数の作成に Node.js 環境が必要ですのでご注意ください。

大きな処理の流れは以下のとおりです。

information

Twitter API の利用には申請が必要です(2019/5 現在)。詳細は Twitter社の公式情報 (External link) を確認してください。

ではさっそく準備に取り掛かりましょう。

kintone の設定

kintone アプリの作成

フィールド名 フィールドタイプ フィールドコード 備考
ツイート日時 日時 tweetDate 必須項目にする
ツイート内容 文字列(複数行) tweetBody 必須項目にする
画像ファイル 添付ファイル imageFile
ツイートURL リンク(Webサイトのアドレス) tweetUrl ツイート後、ツイート個別の URL を自動セット

API トークンの発行

「アプリの設定 > API トークン」で、レコードの閲覧、レコード編集が可能な API トークンを発行します。
ツイート後にステータスを更新し、ツイート URL を登録するのでレコード編集権限が必要です。

プロセス管理

「アプリの設定 > プロセス管理」で、以下のとおり設定します。
「ツイート待ち」ステータスでは作業者を設定しないでください。
設定すると API トークンによるステータス更新ができません。(詳細は 1 件のレコードのステータスを更新する を確認してください)

Twitter の設定

Twitter アカウントの作成

下記 URL から、Twitter アカウントを作成します。
Twitter アカウントを取得済みの方はログインしてください。
https://twitter.com/ (External link)

申請

Twitter API の利用には申請が必要です(2019/5 現在)。
Twitter API の利用が初めての方は、下記 URL から申請してください。
詳細は Twitter 社の公式情報を確認してください。
https://developer.twitter.com/en/portal/dashboard (External link)

アプリケーションの登録

次の URL にアクセスして、下記手順を参考に新しいアプリケーションを作成します。

https://developer.twitter.com/en/portal/dashboard (External link)

  1. 左サイドメニューから「Project & Apps」の「Overview」を選択します。
  2. 「Standalone Apps」の「Create App」からアプリを作成します。
  3. アプリ名を入力し「Complete」をクリックすると、画面に「API key」「API secret key」「Bearer token」が表示されます。
    「API key」と「API secret key」は実装に必要となるので、手元にコピーしておいてください。
  4. 「App Settings」をクリックすると設定画面に遷移します。
  5. 画面上部の「Key and tokens」を開き、「Access token & secret」の横にある「Generate」ボタンを押します。
    生成された「Access token」と「Access token secret」をメモします。
    この情報は一度しか表示されないので、忘れた場合は再生成が必要になります。

Lambda 関数の実行ファイル作成

Node.js をインストールした環境での作業になります。
以下のサンプルコードをファイル名「index.js」で保存します。

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
/*
 * kintone に登録された内容を Twitter へ投稿するサンプルプログラム
 * Copyright (c) 2017 Cybozu
 *
 * Licensed under the MIT License
 * https://opensource.org/license/mit/
 */
const Twitter = require('twitter');
const {DateTime} = require('luxon');
const https = require('https');
const querystring = require('querystring');
const path = require('path');

// Kintone subdomain and App information
const DOMAIN = process.env.KINTONE_DOMAIN;
const APP_ID = process.env.KINTONE_APP_ID;
const API_TOKEN = process.env.KINTONE_API_TOKEN;

// Twitter app information
const twitterClient = new Twitter({
  consumer_key: process.env.TWITTER_API_KEY,
  consumer_secret: process.env.TWITTER_API_SECRET,
  access_token_key: process.env.TWITTER_ACCESS_TOKEN_KEY,
  access_token_secret: process.env.TWITTER_ACCESS_TOKEN_SECRET
});

const getOptions = (apiPath, method) => {
  'use strict';
  return {
    hostname: DOMAIN,
    port: 443,
    path: apiPath,
    method: method,
    headers: {
      'X-Cybozu-API-Token': API_TOKEN
    }
  };
};

// Retrieve records from Kintone if the status is "Tweet scheduled" and the Tweet datetime is past the current datetime
const getRecords = () => {
  'use strict';
  return new Promise((resolve, reject) => {
    console.log('[START] get kintone records');

    // Retrieve the current datetime
    const l = DateTime.local();
    const currentDate = l.toISO();
    const params = {
      app: APP_ID,
      query: `ステータス = "ツイート待ち" and tweetDate <= "${currentDate}"`,
      fields: ['$id', 'tweetBody', 'imageFile']
    };
    const query = querystring.stringify(params);
    const options = getOptions('/k/v1/records.json?' + query, 'GET');

    const req = https.request(options, (res) => {
      res.setEncoding('utf-8');
      res.on('data', (chunk) => {
        if (res.statusCode === 200) {
          resolve(JSON.parse(chunk).records);
        } else {
          reject(JSON.parse(chunk).message);
        }
      });
    }).on('error', (err) => {
      console.log('error: ', err.stack);
      reject(new Error(err));
    });
    req.end();
    console.log('[END] get kintone records');
  });
};

// Download image from Kintone
const downloadImageFile = (fileInfo) => {
  'use strict';
  return new Promise((resolve, reject) => {
    console.log('[START] download image file');

    const params = {
      fileKey: fileInfo.fileKey
    };
    const query = querystring.stringify(params);
    const options = getOptions('/k/v1/file.json?' + query, 'GET');

    const req = https.request(options, (res) => {
      const fileData = [];
      res.on('data', (chunk) => {
        if (res.statusCode === 200) {
          fileData.push(chunk);
        } else {
          reject(JSON.parse(chunk).message);
        }
      });
      res.on('end', () => {
        if (res.statusCode === 200) {
          resolve(Buffer.concat(fileData));
        }
      });
    }).on('error', (err) => {
      console.log('error:', err.stack);
      reject(new Error(err));
    });
    req.end();
    console.log('[END] download image file');
  });
};

// Upload image to Twitter
const mediaUpload = (fileData) => {
  'use strict';

  return new Promise((resolve, reject) => {
    console.log('[START] media upload');
    twitterClient.post('media/upload', {media: fileData})
      .then((media) => {
        resolve(media.media_id_string);
      }).catch((err) => {
        console.log('error:', err.stack);
        reject(new Error(err));
      });
  });

};

// Post to Twitter
const tweet = (tweetBody, mediaIdList) => {
  return new Promise((resolve, reject) => {
    'use strict';
    console.log('[START] tweet');

    const status = {
      status: tweetBody,
      media_ids: mediaIdList.join(',')
    };
    twitterClient.post('statuses/update', status, (err, tweetRes, res) => {
      if (err !== null) {
        reject(JSON.stringify(err));
      } else {
        resolve(tweetRes);
      }
    });
  });
};

// Update the Kintone status and enter the Tweet URL
const updateStatus = (rid, tweetUrl) => {
  'use strict';
  console.log('[START] kintone status update');
  return new Promise((resolve, reject) => {
    const params = {
      requests: [
        {
          method: 'PUT',
          api: '/k/v1/record/status.json',
          payload: {
            app: APP_ID,
            id: rid,
            action: 'ツイートしたよ'
          }
        },
        {
          method: 'PUT',
          api: '/k/v1/record.json',
          payload: {
            app: APP_ID,
            id: rid,
            record: {
              tweetUrl: {
                value: tweetUrl
              }
            }
          }
        }
      ]
    };
    const options = getOptions('/k/v1/bulkRequest.json', 'POST');
    options.headers['Content-Type'] = 'application/json';

    const req = https.request(options, (res) => {
      res.setEncoding('utf-8');
      res.on('data', (chunk) => {
        if (res.statusCode === 200) {
          resolve(rid);
        } else {
          reject(JSON.parse(chunk).message);
        }
      });
    }).on('error', (err) => {
      console.log('error: ', err.stack);
      reject(new Error(err));
    });
    req.write(JSON.stringify(params));
    req.end();
  });
};

const handleRecord = (record) => {
  'use strict';

  const rid = record.$id.value;
  console.log(`[START] record id: ${rid}`);
  const tweetBody = record.tweetBody.value;

  const downloadImageHandlers = record.imageFile.value.map((imageFile) => {
    const ext = path.extname(imageFile.name).toLowerCase();
    if (ext === '.png' || ext === '.gif' || ext === '.jpg' || ext === '.jpeg') {
      return downloadImageFile(imageFile);
    }
  });
  return Promise.all(downloadImageHandlers)
    .then((fileDataList) => {
      const mediaUploadHandlers = fileDataList.map((fileData) => {
        return mediaUpload(fileData);
      });
      return Promise.all(mediaUploadHandlers);
    }).then((mediaIdList) => {
      return tweet(tweetBody, mediaIdList);
    }).then((tweetRes) => {
      const tweetUrl = `https://twitter.com/${tweetRes.user.screen_name}/status/${tweetRes.id_str}`;
      return updateStatus(rid, tweetUrl);
    }).catch((err) => {
      throw new Error(err);
    });
};

exports.handler = () => {
  'use strict';
  return getRecords()
    .then((records) => {
      if (records.length === 0) {
        console.log('[COMPLETE] nothing to do');
      } else {
        const promises = records.map((record) => {
          return handleRecord(record);
        });
        return Promise.all(promises).then((rid) => {
          console.log('[COMPLETE] record id: ' + rid.join(', '));
        }).catch((err) => {
          throw new Error(err);
        });
      }
    }).catch((err) => {
      console.log(err);
    });
};

以下のコマンドを実行して、モジュールのインストール、ZIP ファイル(Lambda 関数の実行ファイル)を作成します。
ZIP コマンドでエラーが発生した場合は、階層にファイル「index.js」とディレクトリー「node_modules」が存在するか確認してください。

1
2
npm install twitter luxon https querystring path
zip -rq kintone-to-twitter.zip index.js node_modules

AWS の設定

AWS アカウントの作成

次の URL を参考に AWS アカウントと管理者ユーザーを作成します。
AWS アカウントを取得済みの方はログインしてください。
https://aws.amazon.com/jp/register-flow/ (External link)

実行ロールの作成

次の URL を参考に Lambda を実行するロールを作成します。
https://docs.aws.amazon.com/ja_jp/lambda/latest/dg/lambda-intro-execution-role.html (External link)

今回のロール名は「AWSLambdaExecute(例)」で作成しました。

Lambda の設定

以下の例を参考に、新規関数を作成します。
設計図はブランク関数を選択します。

  1. 「関数名」は適当な値を設定します。

  2. 「ランタイム」は Node.js 12.x を選択します。

  3. 「デフォルトの実行ロールの変更」から「既存のロールを使用する」を選択し、先ほど作成したロールを設定します。

  4. 関数を作成したら、「トリガーの追加」をクリックします。

  5. ドロップダウンから、トリガーとして「EventBridge(CloudWatch Events)」を選択します。

    information

    EventBridge の使用は、イベントの数に基づいて課金されるので注意してください。
    詳細については、 AmazonEventBridgeの料金 (External link) ページを参照してください。

  6. 「ルール名」、「ルールの説明」、「スケジュール式」には適当な値を入力します。

  7. 関数の設定に戻り、「kintone-to-Twitter」をクリックして、関数コードから「アクション」を開きます。

  8. 「.zip ファイルをアップロード」から、先ほど作成した kintone-to-Twitter.zip をアップロードします。

  9. 「ランタイム設定」で「ハンドラー」に index.handler が選択されていない場合は、編集ボタンを押して選択します。

  10. 「基本設定」の「編集」ボタンを押し、「タイムアウト」と「実行ロール」を編集します。

  11. 「タイムアウト」には 30 秒を設定します。

  12. 「実行ロール」は「既存のロールを使用する」を選択し、「既存のロール」のドロップダウンから先ほど作成したロールを設定します。

  13. 最後に、環境変数を追加します。

  14. 環境変数の設定は index.js の 環境変数の設定 の部分を参考に、「process.env.」に続く大文字部分を「キー」に入れ、それぞれ対応する値を設定します。

環境変数の設定
13
14
15
16
17
18
19
20
21
22
23
24
// Kintone subdomain and App information
const DOMAIN = process.env.KINTONE_DOMAIN;
const APP_ID = process.env.KINTONE_APP_ID;
const API_TOKEN = process.env.KINTONE_API_TOKEN;

// Twitter app information
const twitterClient = new Twitter({
  consumer_key: process.env.TWITTER_API_KEY,
  consumer_secret: process.env.TWITTER_API_SECRET,
  access_token_key: process.env.TWITTER_ACCESS_TOKEN_KEY,
  access_token_secret: process.env.TWITTER_ACCESS_TOKEN_SECRET
});

動作確認

kintone にデータを登録して、プロセス管理のステータスを「ツイート待ち」まで進めましょう。

ツイート日時経過後に Lambda が実行されたタイミングでツイートされました!

kintone のデータを確認すると、ステータスが進み、ツイート URL に値がセットされています。

おわりに

ばっちりツイートされましたね!
これなら安心して Twitter 運用できそうです。

設定が少したいへんですがぜひチャレンジしてみてください。

information

この Tips は、2017 年 4 月版 kintone で動作を確認しています。