Cisco Webex MessagingからGaroonスケジュールを予約する

目次

caution
警告

はじめに

緊急な来訪で今すぐ会議室を抑えておきたいケースがあると思います。
グループウェアが未導入だと担当者に電話で確認したり、会議室前の予約用紙にサインしたりと面倒ですが、グループウェアを導入済みでもパソコンを立ち上げたり、空き状況を検索したりと意外と手間もかかります。

そこで、今回はクラウドベースのコラボレーションサービス『 Cisco Webex Messaging (External link) 』と大規模向けグループウェア『 Garoon (External link) 』を連携することで、簡単に空き部屋を見つけて予約するTipsを紹介します。

システム構成図

Cisco Webex Messagingと**Garoon on cybozu(以下Garoon)**のAPIを連携させるために、今回はAWS Lambdaを利用しました。

Cisco Webex Messagingとは

  • 1対ゼロから、複数メンバーによるグループメッセージでシームレスに会話が成り立つ。
  • モバイル、パソコン専用のCisco Webex Messagingアプリがあり、複数のデバイス利用が可能
  • RESTfulなAPIがそろっており、Webhookも搭載

そのほか多くの特徴があります Cisco Webex Messagingのサイト (External link) で確認してください。

下準備

Garoon

  • 必要に応じて施設を登録してください。
  • API実行用のユーザーを1つ用意してください。

Cisco Webex Messaging

  • Cisco Webex Messaging上に結果を表示するためのbot用のアカウントを用意してください。
  • 専用の会議室を1つ用意してください。
  • Cisco Webex MessagingのAPI情報は こちら (External link) のページです。

Node.jsのJavaScriptファイル

  • 後述の index.js, garoonapi.jsの内容をコピーして配置してください。
  • request-promise, moment, cheerioを使用していますので、必要に応じてnpm installでインストールしてください。

環境を作成する

アップロード用Zipファイルの作成

index.jsを開いて、以下の項目を記入して保存します。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
// Cisco Webex Messagingの会議室のID
const ROOMID = 'XXX';

// Cisco Webex MessagingのbotアカウントのAccess Token
const BEARER = 'XXX';

// Garoonのドメイン
const DOMAIN = '{subdomain}.cybozu.com';

// Cisco Webex Messagingのbotアカウントのメールアドレス
const SPARK_MADDRESS = 'xxx@xx.xx';

項目 設定値
ROOMID Cisco Webex Messagingの会議室のID
List Rooms (External link) で該当する会議室のIDを確認できます。
Test ModeをOnにして(ログインする必要あり) Runをクリックすると、会議室の情報がResponseに表示されます。
Responseの中から、該当する会議室のIDを取得してください。
BEARER Cisco Webex MessagingのbotアカウントのAccess Token
Webex for developer (External link) のログイン後に右上のユーザーのアイコンをクリックすると表示されます

【補足】
OAuth 2.0を利用した認証も可能です。複数のアカウントに関連する操作を行いたい場合や、APIへのアクセス権を制限したい場合などには、OAuth 2.0を利用する必要があります。
詳細は、 Integrations & Authorization (External link) を参照ください。
DOMAIN kintoneのドメイン
SPARK_MADDRESS Cisco Webex Messagingのbotアカウントのメールアドレス

同様にGaroonpi.jsを開いて、以下の項目を記入して保存します。

1
2
const USERID = xxx, // API実行ユーザーのログイン名
  PASSWD = xxx; // API実行ユーザーのパスワード

項目 設定値
USERID API実行ユーザーのログイン名
PASSWD API実行ユーザーのパスワード

最後に、以下のコマンドで、zipファイルを作成します。

zipファイルの名前は、SparkGSch.zipとします。

1
zip -r SparkGSch.zip index.js garoonapi.js node_modules/

AWSの設定(Lambda, API Gateway)

Slackから手軽にkintoneへレコード登録する方法の中の「Lambdaの設定」「Amazon API Gatewayの設定」を参考に、それぞれの設定します。

ここでは、注意すべき点を記載します。

Lambdaの設定
  • Run TimeでNodeのバージョンが指定できます。
    最新バージョン(記事作成時点では、Node.js 4.3)を指定することをおすすめします。
  • Advanced settingsのTimeoutは0min 20secとしてください。
    環境によりますが7~8秒かかる場合もあります。
API Gatewayの設定

Invoke URLの中にDomain名、Stage名、Method名が含まれていることを確認してください。

1
https://(Domain名)/(Stage名)/(Method名)

Cisco Webex Messaging Webhookの設定

Webex for DevelopersのCreate a Webhook (External link) より、Webhookを作成します。
Test ModeをOnにして(ログインする必要あり)、以下の項目を入力後、Runをクリックしてください。

項目 設定値
name 今回作成するWebhookの名前(任意)
targetUrl API GatewayのInvoke URL
resource messages(固定)
event created(固定)
filter roomId=(Cisco Webex Messagingの会議室のID)

List Webhooks (External link) Get Webhook Details (External link) から作成したWebhookを確認できます。

確認する

Cisco Webex Messagingの会議室から実際に確認してみましょう。

caution
警告

必ず、bot以外のアカウントで確認してください。
botアカウントで確認した場合、プログラムは作動しません。

いまから1時間空いている施設を検索する場合は、「1時間で検索して」と入力します。
スクリーンショットはパソコン版の画面です。

数秒待つとbotがスケジュールの空き状況を教えてくれます。

施設を予約する

先ほどの検索結果より、会議室Bを1時間予約したいと思います。
会議室Bは[2]ですので、以下のように入力します。

数秒待つとbotから、会議室を予約しましたとメッセージが返ってきます。

実際に、Cybozu Garoonで施設のスケジュールを見てみます。
実行した時から1時間の時間帯で、会議室Bが予約されていることを確認できます。

応用編

今回は、現在の時刻から検索・予約する方法を紹介しました。
たとえば施設名に収容員数を含ませることで、人数による絞り込みができたり、施設を検索・予約する際に時間を指定したりするなど、いろいろカスタマイズしてオリジナルのシステムを構築できます。

今後の展開

リアルタイム性を活かして、今すぐ知りたい、教えてくれるサービス展開ができそうです。

  1. 朝に自分の今日の予定をCisco Webex Messagingで取得する。
  2. 他の人の予定を取得する。

JavaScript

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
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
(function() {

  'use strict';

  const rp = require('request-promise');
  const moment = require('moment');
  const gapi = require('./garoonapi');
  const cheerio = require('cheerio');

  // Cisco Webex Messagingの会議室のID
  const ROOMID = 'XXX';

  // Cisco Webex MessagingのbotアカウントのAccess Token
  const BEARER = 'XXX';

  // Garoonのドメイン
  const DOMAIN = '{subdomain}.cybozu.com';

  // Cisco Webex Messagingのbotアカウントのメールアドレス
  const SPARK_MADDRESS = 'xxx@xx.xx';

  // Cisco Webex Messagingにレコードを登録
  function sendSpark(msg) {

    // Cisco Webex Messagingに投稿する内容
    const body_post_spark = {
      roomId: ROOMID, // 会議室
      text: msg // 投稿内容
    };

    // Cisco Webex Messagingに投稿するためのオブジェクト
    const postspark = {
      url: 'https://api.ciscospark.com/v1/messages/',
      method: 'POST',
      auth: {bearer: BEARER},
      'Content-Type': 'application/json',
      json: body_post_spark
    };

    // 投稿を実行する
    rp(postspark).then((res) => {
      console.log('投稿されました:' + msg);
    });
  }

  // garoonに登録されているすべての施設のidを取得する
  function getFacilityId() {

    // 施設のidを取得するためのオブジェクト
    const getfid = {url: 'https://' + DOMAIN + '/g/cbpapi/schedule/api.csp',
      method: 'POST',
      body: gapi.scheduleGetFacilityVersions(),
      'Content-Type': 'text/xml; charset=UTF-8'};

    // 施設のidの取得を実行する
    return rp(getfid).then((res) => {
      const aryfid = [],
        $ = cheerio.load(res),
        aryfobj = $('facility_item');

      for (let i = 0; i < aryfobj.length; i += 1) {
        aryfid.push(aryfobj[i].attribs.id);
      }
      return aryfid;
    });

  }

  // garoonに登録されているすべての施設の詳細情報を取得する
  function getFacilityDetail(aryfid) {
    const objfacility = {},
      // 施設の詳細情報を取得するためのオブジェクト
      getfinfo = {url: 'https://' + DOMAIN + '/g/cbpapi/schedule/api.csp',
        method: 'POST',
        body: gapi.scheduleGetFacilitiesById(aryfid),
        'Content-Type': 'text/xml; charset=UTF-8'};

    // 施設の詳細情報の取得を実行する
    return rp(getfinfo).then((res2) => {
      const $ = cheerio.load(res2),
        aryfdetail = $('facility');
      for (let j = 0; j < aryfdetail.length; j += 1) {
        objfacility[aryfdetail[j].attribs.key] = {name: aryfdetail[j].attribs.name};
      }

      return objfacility;
    });
  }

  // 分をHH:mm:ss形式に変換する 例) 80分 → 01:20:00
  function changeKintoneTimeByMinute(pmin) {
    const minute = parseInt(pmin, 10);

    // 分は1から1439までとする
    if (minute < 1 || minute > 1439) {
      sendSpark('【エラー】時間は1~1439を指定してください:' + pmin);
      return false;
    }

    // 分をHH:mm:ss形式に変換する
    const dt = moment('2016-01-01T00:00:00');
    dt.set('minute', minute);
    return dt.format('HH:mm:ss');
  }

  // Cisco Webex Messagingの入力文字列から時間と分を取得する
  function getHourMinute(str) {
    const objreq = {};

    // '時間'で文字列を分割する
    const aryItem = str.split('時間');

    // '時間' が含まれていない場合
    if (aryItem.length === 1) {
      // 分を取得する(時間は0をセットする)
      objreq.hour = 0;
      objreq.minute = parseInt(aryItem[0].replace(/[^-^0-9^.]/g, ''), 10);

      // '時間' が含まれている場合
    } else {
      // 時間と分を取得する
      objreq.hour = parseInt(aryItem[0].replace(/[^-^0-9^.]/g, ''), 10);
      objreq.minute = parseInt(aryItem[1].replace(/[^-^0-9^.]/g, ''), 10) || 0;
    }

    return objreq;
  }

  // 施設の空き時間を検索する
  function getGaroonSchedule(userid, groupid, facilityid, obj) {
    // 施設の空き時間を取得するためのオブジェクト
    const getgsch = {
      url: 'https://' + DOMAIN + '/g/cbpapi/schedule/api.csp',
      method: 'POST',
      body: gapi.scheduleSearchFreeTimes(userid, groupid, facilityid, obj),
      'Content-Type': 'text/xml; charset=UTF-8'
    };

    // 検索を実行する
    return rp(getgsch).then((res) => {
      const ary = [],
        $ = cheerio.load(res),
        sch = $('candidate');
      // 取得した結果を配列に格納する
      for (let i = 0; i < sch.length; i += 1) {
        // 開始日時、終了日時、施設のid
        ary.push({start: sch[i].attribs.start,
          end: sch[i].attribs.end,
          facility_id: sch[i].attribs.facility_id});
      }
      return ary;
    });
  }

  // garoonの施設に予約を登録する
  function setGaroonSchedule(userid, groupid, facilityid, obj) {
    // 施設のスケジュールを予約するためのオブジェクト
    const reservegsch = {
      url: 'https://' + DOMAIN + '/g/cbpapi/schedule/api.csp',
      method: 'POST',
      body: gapi.scheduleAddEvents(userid, groupid, facilityid, obj),
      'Content-Type': 'text/xml; charset=UTF-8'
    };

    // スケジュールの予約を実行する
    return rp(reservegsch).then((res) => {
      const ary = [],
        $ = cheerio.load(res),
        sch = $('datetime');
      // 予約された内容の開始日時、終了日時
      ary.push({start: sch[0].attribs.start,
        end: sch[0].attribs.end});
      return ary;

      // 予約に失敗した場合
    }).catch((error) => {
      sendSpark('【予定登録失敗】\n' +
                      '予約できませんでした。すでに予約が入っている可能性があります。');
    });
  }

  // 施設の空き時間を検索する準備をする
  function searchFreeTime(str) {

    const gettime = {url: 'https://' + DOMAIN + '/g/',
        method: 'HEAD',
        'Content-Type': 'text/xml; charset=UTF-8'},
      arystr = [];
    let objreq = {},
      dtstart, dtend,
      aryfid = [],
      objfdetail = {},
      sdt, edt, facilityname;

    // Cisco Webex Messagingの入力文字列から検索する空き時間の時間、分を取得する
    objreq = getHourMinute(str);

    // 施設を検索する時間の長さ(分)を計算する
    objreq.timescale = changeKintoneTimeByMinute(objreq.hour * 60 + objreq.minute);

    // Garoonに登録されているすべての施設のIDを取得する
    return getFacilityId().then((fid) => {

      // 施設のidは後で使用するので、保持しておく
      aryfid = fid;

      // Garoonに登録されているすべての施設の詳細情報を取得する
      return getFacilityDetail(aryfid);

    }).then((fdetail) => {

      // 施設の詳細情報は後で使用するので、保持しておく
      objfdetail = fdetail;

      // 現在の時刻(kintoneサーバーの時刻)を取得する
      return rp(gettime);

    }).then((res) => {

      // 空き時間検索の開始日時
      dtstart = moment(new Date(res.date));

      // 空き時間検索の終了日時
      dtend = moment(new Date(res.date));
      dtend.set('hour', dtend.get('hour') + objreq.hour);
      dtend.set('minute', dtend.get('minute') + objreq.minute);

      // 時刻をkintoneフォーマットに合わせる
      objreq.dt = [];
      objreq.dt[0] = {start: dtstart.format('YYYY-MM-DDTHH:mm:ss') + 'Z',
        end: dtend.format('YYYY-MM-DDTHH:mm:ss') + 'Z'};

      // 施設のidと日付からスケジュールの取得を実行する
      return getGaroonSchedule([], [], aryfid, objreq);

    }).then((result) => {

      // 検索結果が0件の場合は、メッセージを出力して処理を抜ける
      if (result.length < 1) {
        sendSpark('【結果】\nスケジュールに空きが見つかりませんでした');
        return;
      }

      // 結果を配列に格納する
      for (let i = 0; i < result.length; i += 1) {
        // 開始日時、終了日時
        sdt = new Date(result[i].start);
        edt = new Date(result[i].end);
        // 施設の名前
        facilityname = objfdetail[result[i].facility_id].name;
        // 日時を日本時間に変換する
        sdt.setHours(sdt.getHours() + 9);
        edt.setHours(edt.getHours() + 9);
        // 配列に追加する
        arystr.push('[' + result[i].facility_id + '] ' + facilityname + ' ' +
                            moment(sdt).format('YYYY/MM/DD HH:mm') + ' - ' + moment(edt).format('HH:mm'));
      }
      // Cisco Webex Messagingに結果を出力する
      sendSpark('【検索結果】\n' + arystr.join('\n'));


    }).catch((error) => {

    });
  }

  // 施設を検索、登録する
  function reservation(str) {
    const gettime = {url: 'https://' + DOMAIN + '/g/',
        method: 'HEAD',
        'Content-Type': 'text/xml; charset=UTF-8'},
      aryItem = [],
      aryresistfid = [];
    let
      objreq = {},
      dtstart, dtend,
      sdt, edt;

    // Cisco Webex Messagingの入力文字列を 'を' で分割する
    aryItem[0] = str.split('を');

    // 'を' が含まれている数が、1つではない場合は、処理を抜ける
    if (aryItem[0].length !== 2) {
      return null;
    }

    // 予約する施設のidを配列に格納する
    // 【補足】同時に複数の施設を予約したい場合は、さらに追加してください
    if (parseInt(aryItem[0][0].replace(/[^-^0-9^.]/g, ''), 10) > 0) {
      aryresistfid.push(parseInt(aryItem[0][0].replace(/[^-^0-9^.]/g, ''), 10));
    } else {
      sendSpark('施設IDが不正です: ' + aryItem[0][0]);
      return null;
    }

    // Cisco Webex Messagingの入力文字列から検索する空き時間の時間、分を取得する
    objreq = getHourMinute(aryItem[0][1]);

    // 施設を予約する時間(分)
    objreq.timescale = changeKintoneTimeByMinute(objreq.hour * 60 + objreq.minute);

    // 現在の時刻(kintoneサーバーの時刻)を取得する
    return rp(gettime).then((res) => {

      // 予約の開始日時(現在のサーバー時刻)
      dtstart = moment(new Date(res.date));

      // 予約の終了日時
      dtend = moment(new Date(res.date));
      dtend.set('hour', dtend.get('hour') + objreq.hour);
      dtend.set('minute', dtend.get('minute') + objreq.minute);

      // 開始日時と終了日時を 'YYYY-MM-DDTHH:mm:ss' 形式に変換する
      objreq.sdt = dtstart.format('YYYY-MM-DDTHH:mm:ss') + 'Z';
      objreq.edt = dtend.format('YYYY-MM-DDTHH:mm:ss') + 'Z';

      // Garoonのスケジュールに登録する際のタイトル
      objreq.title = '★From Spark';

      // 予約を実行する
      return setGaroonSchedule([], [], aryresistfid, objreq);

    }).then((result) => {

      // 予約されたスケジュールの開始日時、終了日時
      sdt = new Date(result[0].start);
      edt = new Date(result[0].end);

      // 日時を日本時間に変換する
      sdt.setHours(sdt.getHours() + 9);
      edt.setHours(edt.getHours() + 9);

      // Cisco Webex Messagingに結果を出力する
      sendSpark('【予定登録完了】\n' +
                      '[' + aryresistfid[0] + '] ' + moment(sdt).format('YYYY/MM/DD HH:mm') +
                        ' - ' + moment(edt).format('HH:mm') + ' で会議室を予約しました');

    }).catch((error) => {
      return null;
    });
  }

  // 入力された文字の内容によって処理を振り分ける
  function allocation(str) {
    // 検索
    // Cisco Webex Messagingに入力された文字が条件を満たしている場合のみ検索を実行する
    if ((str.indexOf('時間') > -1 || str.indexOf('分') > -1) && str.indexOf('検索') > -1) {
      searchFreeTime(str);
      return;
    }

    // 予約
    // Cisco Webex Messagingに入力された文字が条件を満たしている場合のみ予約を実行する
    if (str.indexOf('を') > -1 &&
            (str.indexOf('時間') > -1 || str.indexOf('分') > -1) &&
            (str.indexOf('登録') > -1 || str.indexOf('予約')) > -1) {
      reservation(str);

    }

  }

  // Webhookを受けた際の処理
  exports.handler = function(event, context) {
    // 【注意】
    // Webhookはインターネットにさらされているので、セキュリティに関して注意する必要があります。
    // Webhookは、httpsで受信するように設計したうえで、イベント受信時に、
    // event.idで取得したWebhookのIDが、Webhook登録時のレスポンスのWebhookのIDと同じかどうかをチェックする必要があります。
    // また、登録時に指定したフィルタに適合しているかチェックすることも推奨します。
    // 本記事では、サンプルにつき、詳細は省略しています。

    // event.data.idで投稿されたメッセージのidを取得できる
    // idを利用してメッセージの詳細を取得する
    const getmessage = {
      url: 'https://api.ciscospark.com/v1/messages/' + event.data.id,
      method: 'GET',
      auth: {bearer: BEARER},
      'Content-Type': 'application/json'
    };

    // メッセージの詳細の取得を実行する
    rp(getmessage).then((body) => {
      const objbody = JSON.parse(body);
      // botアカウントからのメッセージの場合は、処理しない(無限ループを防ぐ)
      if (objbody.personEmail === SPARK_MADDRESS) {
        return;
      }
      // メッセージの本文を解析する
      allocation(objbody.text);
    });
  };

  // zipファイルを作成する際のコマンド
  // zip -r SparkGSch.zip index.js garoonapi.js node_modules/

})();

garoonapi.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

(function() {

  'use strict';

  const USERID = 'xxx', // API実行ユーザーのログイン名
    PASSWD = 'xxx'; // API実行ユーザーのパスワード

  // xmlの共通部分
  function soapFormat(fname) {
    const obj = {};
    obj.head = '<?xml version="1.0" encoding="UTF-8"?>' +
                    '<soap:Envelope xmlns:soap="http://www.w3.org/2003/05/soap-envelope">' +
                      '<soap:Header>' +
                        '<Action>' + fname + '</Action>' +
                        '<Security>' +
                          '<UsernameToken>' +
                            '<Username>' + USERID + '</Username>' +
                            '<Password>' + PASSWD + '</Password>' +
                          '</UsernameToken>' +
                        '</Security>' +
                        '<Timestamp>' +
                          '<Created>2010-08-12T14:45:00Z' +
                          '<Expires>2037-08-12T14:45:00Z' +
                        '</Timestamp>' +
                        '<Locale>jp' +
                      '</soap:Header>' +
                      '<soap:Body>' +
                        '<' + fname + '>';

    obj.foot = '</' + fname + '>' +
                  '</soap:Body>' +
                '</soap:Envelope>';

    return obj;
  }

  // スケジュールの空き時間を検索する
  exports.scheduleSearchFreeTimes = function(userid, groupid, facilityid, obj) {
    const sf = soapFormat('ScheduleSearchFreeTimes');
    let
      membertag = '',
      dttag = '';

    for (let i = 0; i < userid.length; i += 1) {
      membertag += '<member><user id="' + userid[i] + '"></member>';
    }

    for (let j = 0; j < groupid.length; j += 1) {
      membertag += '<member><organization id="' + groupid[j] + '"></member>';
    }

    for (let k = 0; k < facilityid.length; k += 1) {
      membertag += '<member><facility id="' + facilityid[k] + '"></member>';
    }

    for (let m = 0; m < obj.dt.length; m += 1) {
      dttag += '<candidate start="' + obj.dt[m].start + '" end="' + obj.dt[m].end + '">';
    }

    return sf.head + '<parameters search_time="' + obj.timescale + '" search_condition="or">' +
               dttag +
               membertag +
              '</parameters>' + sf.foot;
  };

  // スケジュールに予定を登録する
  exports.scheduleAddEvents = function(userid, groupid, facilityid, obj) {
    const sf = soapFormat('ScheduleAddEvents');
    let membertag = '';

    for (let i = 0; i < userid.length; i += 1) {
      membertag += '<member><user id="' + userid[i] + '"></member>';
    }

    for (let j = 0; j < groupid.length; j += 1) {
      membertag += '<member><organization id="' + groupid[j] + '"></member>';
    }

    for (let k = 0; k < facilityid.length; k += 1) {
      membertag += '<member><facility id="' + facilityid[k] + '"></member>';
    }

    return sf.head + '<parameters>' +
               '<schedule_event xmlns="" id="dummy" event_type="normal" version="dummy"' +
               ' public_type="public"' +
               ' detail="' + obj.title + '" start_only="false">' +
               '<members>' + membertag + '</members>' +
               '<when>' +
               '</schedule_event>' + sf.foot;
  };

  // 施設の詳細情報を取得する
  exports.scheduleGetFacilitiesById = function(ids) {
    const sf = soapFormat('ScheduleGetFacilitiesById');
    let facility = '';
    for (let i = 0; i < ids.length; i += 1) {
      facility += '<facility_id>' + ids[i] + '</facility_id>';
    }
    return sf.head + '<parameters>' + facility + '</parameters>' + sf.foot;
  };

  // Garoonに登録されているすべての施設のidを取得する
  exports.scheduleGetFacilityVersions = function() {
    const sf = soapFormat('ScheduleGetFacilityVersions');
    return sf.head + '<parameters>' + sf.foot;
  };

})();
information

このTipsは、2016年4月時点のGaroon, Cisco Webex Messagingで動作を確認しています。