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

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

目次

はじめに

皆さん、X(旧Twitter)を使っていますか?

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

運用イメージ

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

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

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

information

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

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

kintoneの設定

kintoneアプリの作成

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

APIトークンの発行

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

プロセス管理

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

Xの設定

Xアカウントの作成

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

申請

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

アプリケーションの登録

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

https://developer.x.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」をメモします。
    この情報は一度しか表示されないので、忘れた場合は再生成が必要になります。
  6. 画面上部の「Settings」を開き、「User authentication settings」にある「Edit」ボタンを押します。
    • 「App permissions」で、「Read and write」を選択します。
    • 「Type of App」で、「Web App, Automated App or Bot」を選択します。
    • 「App info」で、任意の情報を入力します。 画面下部の「Save」を押します。

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
/*
 * kintoneに登録された内容をXへ投稿するサンプルプログラム
 * Copyright (c) 2025 Cybozu
 *
 * Licensed under the MIT License
 * https://opensource.org/license/mit/
 */
const {TwitterApi} = require('twitter-api-v2');
const fs = require('fs');
const {DateTime} = require('luxon');
const https = require('https');
const querystring = require('querystring');
const path = require('path');

// 環境変数から取得した、Kintone APIにアクセスするための情報を設定
const DOMAIN = process.env.KINTONE_DOMAIN;
const APP_ID = process.env.KINTONE_APP_ID;
const API_TOKEN = process.env.KINTONE_API_TOKEN;
const getOptions = (apiPath, method) => ({
  hostname: DOMAIN,
  port: 443,
  path: apiPath,
  method: method,
  headers: {
    'X-Cybozu-API-Token': API_TOKEN
  }
});

// 環境変数から取得した、Twitter APIにアクセスするための情報を設定
const client = new TwitterApi({
  appKey: process.env.TWITTER_API_KEY,
  appSecret: process.env.TWITTER_API_SECRET,
  accessToken: process.env.TWITTER_ACCESS_TOKEN_KEY,
  accessSecret: process.env.TWITTER_ACCESS_TOKEN_SECRET,
});

// kintoneアプリに登録されたレコードのうち、
// 「ポスト待ち」ステータスかつ、ポスト日時を迎えたレコードの情報を取得する関数
const getRecords = () => {
  console.log('[START] get kintone records');
  const currentDate = DateTime.local().toISO();
  const params = {
    app: APP_ID,
    query: `ステータス = "ポスト待ち" and postDate <= "${currentDate}"`,
    fields: ['$id', 'postBody', 'imageFile']
  };
  const query = querystring.stringify(params);
  const options = getOptions('/k/v1/records.json?' + query, 'GET');

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

// kintoneのレコードから画像をダウンロードする関数
const downloadImageFile = (fileInfo) => {
  console.log('[START] download image file');
  console.log(fileInfo.fileKey);
  const params = {fileKey: fileInfo.fileKey};
  const query = querystring.stringify(params);
  const options = getOptions('/k/v1/file.json?' + query, 'GET');

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

// ポストするために、Xに画像をアップロードする関数
const mediaUpload = async (fileData, contentType) => {
  console.log('[START] upload media');
  try {
    const mediaId = await client.v2.uploadMedia(fileData, {
      media_type: contentType,
      media_category: 'tweet_image'
    });
    console.log('[END] upload media');
    return mediaId;
  } catch (error) {
    console.error('メディアのアップロード中にエラーが発生しました:', error);
    throw error;
  }
};

// Xにポストする関数
const postToX = async (postBody, mediaIdList) => {
  console.log('[START] post');
  const postContent = {
    text: postBody
  };
  if (mediaIdList && mediaIdList.length > 0) {
    postContent.media = {media_ids: mediaIdList};
  }
  try {
    const tweetRes = await client.v2.tweet(postContent);
    console.log('[END] post');
    return tweetRes;
  } catch (err) {
    console.error('error:', err.stack);
    throw err;
  }
};

// kintoneのレコードのステータスを変更する関数
const updateStatus = (rid, postUrl) => {
  console.log('[START] kintone status update');
  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: {
            postUrl: {value: postUrl}
          }
        }
      }
    ]
  };
  const options = getOptions('/k/v1/bulkRequest.json', 'POST');
  options.headers['Content-Type'] = 'application/json';

  return new Promise((resolve, reject) => {
    const req = https.request(options, (res) => {
      res.setEncoding('utf-8');
      let data = '';
      res.on('data', (chunk) => {
        data += chunk;
      });
      res.on('end', () => {
        if (res.statusCode === 200) {
          resolve(rid);
        } else {
          reject(new Error('Failed to update status'));
        }
      });
    }).on('error', (err) => reject(err));
    req.write(JSON.stringify(params));
    req.end();
  }).finally(() => console.log('[END] kintone status update'));
};

// ユーザー情報をキャッシュするための変数・関数
let cachedUserInfo = null;
const fetchUserInfo = () => {
  if (cachedUserInfo) {
    return Promise.resolve(cachedUserInfo);
  }
  return client.v2.me()
    .then(user => {
      cachedUserInfo = user;
      return user;
    })
    .catch(err => {
      console.error('Failed to fetch user info:', err);
      throw err;
    });
};

// 一連の処理を実行する関数
const handleRecord = async (record) => {
  const rid = record.$id.value;
  console.log(`[START] function record id: ${rid}`);
  const postBody = record.postBody.value;
  try {
    const fileDataList = await Promise.all(record.imageFile.value.map(downloadImageFile));
    const mediaIdList = await Promise.all(fileDataList.map((fileData, index) => {
      const imageFile = record.imageFile.value[index];
      const ext = path.extname(imageFile.name).toLowerCase();
      const contentType = {
        '.png': 'image/png',
        '.gif': 'image/gif',
        '.jpg': 'image/jpeg',
        '.jpeg': 'image/jpeg'
      }[ext];
      if (!contentType) throw new Error('Unsupported file type');
      return mediaUpload(fileData, contentType);
    }));

    const [tweetRes, userInfo] = await Promise.all([postToX(postBody, mediaIdList), fetchUserInfo()]);
    const postUrl = `https://twitter.com/${userInfo.username}/status/${tweetRes.data.id}`;
    await updateStatus(rid, postUrl);
  } catch (err) {
    console.error('Error handling record:', err);
    throw err;
  }
};

exports.handler = () => {
  return getRecords()
    .then((records) => {
      if (records.length === 0) {
        console.log('[COMPLETE] function nothing to do');
      } else {
        const promises = records.map(handleRecord);
        return Promise.all(promises)
          .then((rids) => console.log('[COMPLETE] function record ids: ' + rids.join(', ')))
          .catch((err) => {
            console.error('Error processing records:', err);
            throw err;
          });
      }
    })
    .catch((err) => {
      console.error('Error in handler:', err);
    });
};

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

1
2
npm install twitter luxon https querystring path
zip -rq kintone-to-X.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 22.xを選択します。

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

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

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

    information

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

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

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

  8. 「.zipファイルをアップロード」から、先ほど作成したkintone-to-X.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;

// X app information
const client = new TwitterApi({
  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に値がセットされています。

おわりに

ばっちりポストされましたね!
これなら安心してX運用できそうです。

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

information

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