Post on X When Records Are Approved

Contents

Overview

This article introduces how to post on X, formerly known as Twitter, automatically after Kintone Records are approved. AWS Lambda is used to facilitate the integration.

The general flow of the operation is as follows:

  1. An employee registers a post as well as the datetime to post in a Kintone record.
  2. A reviewer checks the post contents and approves or rejects it.
  3. If approved, the post is posted at the set datetime. If the datetime is in the past, it is posted immediately.

AWS Lambda is used to receive the webhook data, and either post, or schedule a post to X as the user. When the request is successful it also updates the Kintone record by changing the status to Complete and inserting a link to the post in the record.

Sample Image

The general flow of the customization is as follows:

  1. When the set datetime passes, the Lambda function is triggered and retrieves the X post data from Kintone.
  2. The data is posted on X.
  3. The process management status of the Kintone record is updated. The record is also updated with the URL of the X post.

The following diagram shows the general flow of the customization.

tips
Note

  1. This customization uses a Node.js v22.x environment.
  2. To use the X API, a X developer account is needed. For details, refer to the following page of the X developer website:
    X Subscription Plan (External link)

Prepare the Kintone App

Create the Form

Create an App with the following field and settings. Save the form when finished. For more information, refer to the following page:
Create an App (External link)

Field Type Field Name Field Code Notes
Date and time Post date and time postDate Required field
Text area Post content postBody Required field
Attachment Image file imageFile
Link (URL) Post URL postUrl The URL of the X post is automatically set in this field after the X post is posted.

Generate an API Token

Create an API token with view and edit permissions. For more information, refer to the following page:
Generating API Tokens (External link)

Set Up the Process Management Settings

Open the App Settings tab and navigate to Process Management under General Settings.

Check the Enable process management check box to enable the process management feature.
Do not set a default assignee for the Post scheduled status. If set, the status cannot be updated with the API token. For more information, refer to the following page:
Set Process Management settings (External link)

  • Status Not started, In review, Post scheduled, Completed

  • Process list

    Status Assignee Action Next Status
    Not started Set to anybody Start In review
    In review -
    • OK
    • Not OK
    • Post scheduled
    • Not started
    Post scheduled - Complete Completed

Prepare the X Settings

Prepare the X Account

Log in to X if the necessary X account already exists. If not, create a new X account from the following site:
x.com (External link)

Apply for a Developer Account

An X developer account is required to use the X API. For those using the X API for the first time, refer to the following page of the X developer website:
Subscription Plan (External link)

Register a New App

Access the following page of the X developer website:
Project & Apps (External link)

The default Project App will be used. Create a new app if needed.

Next, navigate to the Keys and tokens tab and make a note of the API key and API secret key under Consumer keys.

Click Generate above the Access token & access token secret box.

Make note of the displayed Access token and Access token secret. They will not be displayed again and if forgotten will need to be regenerated.

Prepare the Lambda Function Executable

This step must be done in an environment where Node.js is installed.

Save the following sample code as a file named 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
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 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;
const getOptions = (apiPath, method) => ({
  hostname: DOMAIN,
  port: 443,
  path: apiPath,
  method: method,
  headers: {
    'X-Cybozu-API-Token': API_TOKEN
  }
});


// X app information
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,
});

// Retrieve records from Kintone if the status is "Post scheduled" and the X post datetime is past the current datetime
const getRecords = () => {
  console.log('[START] get kintone records');
  const currentDate = DateTime.local().toISO();
  const params = {
    app: APP_ID,
    query: `status = "Post scheduled" 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'));
};

// Download image from 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'));
};

// Upload image to X
const mediaUpload = async (fileData, contentType) => {
  console.log('[START] media upload');
  try {
    const mediaId = await client.v2.uploadMedia(fileData, {
      media_type: contentType,
      media_category: 'tweet_image'
    });
    console.log('[END] media upload');
    return mediaId;
  } catch (error) {
    console.error('Error:', error);
    throw error;
  }
};

// Post to 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;
  }
};

// Update the Kintone status and enter the X post URL
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: 'Complete'
        }
      },
      {
        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'));
};

// Cache user information
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;
    });
};

// Function to execute a series of processes
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);
    });
};

Run the following commands to install the module and create the ZIP file (the Lambda function executable).

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

If an error occurs with the ZIP command, check that the file index.js and the directory node_modules exist in the hierarchy.

Prepare the AWS Settings

Create an Execution Role

Create a role to execute Lambda. For more information, refer to the following page:
AWS Lambda execution role (External link)

In this sample, the role is created with the name AWSLambdaExecute as an example.

Create a New Lambda Function

In AWS Lambda, click Create a function.

Choose Author from scratch and enter in the rest of the settings as shown in the image below. The Function name can be anything and the Runtime should be set to Node.js 22.x. The Execution role should is set as the role that was created in the previous step.

After creating the function, click Add trigger.

USe the drop down to select EventBridge (CloudWatch Events) as the trigger.
Enter in any value for Rule and Rule name, and under Schedule expression, set the rate to 1 minute.

tips
Note

Note that usage of EventBridge is charged based on the number of events published. For more information, refer to the following page:
Amazon EventBridge Pricing (External link)

Return to the Lambda Function overview and click Code to open up the Code source. Click to .zip file from Upload fromand upload the Lambda function execution file created earlier, named kintone-to-x.zip.

Navigate to Environment variables and click Edit environment variables.
Click Add environment variable to create a new row of variable settings.

Referring to the following section of the index.js code, create a row of variable settings for each variable, matching up the capitalized portion of the variables with the values obtained earlier.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
// 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
});

The environment variable settings should look like the following:

Navigate down to the General configuration and click Edit.

Enter the settings as follows, checking that the Timeout and Execution role values are correct. Click save when done.

Test the Integration

Add a new record into the Kintone App, filling in the X post content and the desired date of the X post. Proceed the process management states to "X post scheduled".

Check that the X post is posted when the set datetime has passed. If the configuration is successful, the record status should change to Complete and the link to the posted X post should be entered in the Link field.

Limitations

Note that images may be added to the X post with the following conditions:

  1. Accepted file types: JPG, PNG, GIF, WEBP
  2. Maximum file size: 5MB
  3. Maximum static images in one X post (JPG, PNG, WEBP): Up to 4
  4. Maximum GIFs in one X post: 1
  5. GIF files must be uploaded alone and cannot be mixed with other image types.

References