承認されたら 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で動作を確認しています。