DiscordにGoogleCalendarの通知を流すbotをGASで作った

# 動機
私が所属しているとあるチームのコミュニケーションツールをSlackからDiscordに移行した
チームのSlackにはGoogleCalendarのイベントを通知するBotを導入していた
機能は専用のcalendarチャンネルに

- 毎朝今日のイベントを一括で通知
- イベント30分前に個別で通知
- カレンダーを編集した際に通知

でこれを継続的に使用可能な技術で
(つまりdiscord.jsや.pyを使わずに)
Discordで再現したかった

# コード全文
```js
const
t=new Date(),//実行時刻
prop=PropertiesService.getScriptProperties(),//ストレージアクセス
cals=CalendarApp.getAllOwnedCalendars(),//カレンダー
cfg={
  locale:'ja-JP',//時刻表示形式
  main:'07:00',//一括通知の時刻
  trg:30,//N分前の個別通知
  webhooks:[//メッセージを送信するwebhook url 複数可
    'https://discord.com/api/webhooks/00000000/xxxxxxxx'
  ],
  opt:{maxResults:65536,showDeleted:true},//変更非推奨 カレンダー参照設定
  col:[//変更非推奨 イベント色パレット
    null,'#a4bdfc','#7AE7BF','#BDADFF','#FF887C','#FBD75B',
    '#FFB878','#46D6DB','#E1E1E1','#5484ED','#51B749','#DC2127'
  ]
},

fmt=(y,x=CalendarApp.getCalendarById(y.getOriginalCalendarId()))=>({
  //使いやすいようフォーマット
  name:x.getName(),
  title:y.getTitle(),
  color:cfg.col[y.getColor()]||x.getColor(),
  desc:y.getDescription(),
  isAD:y.isAllDayEvent(),
  time:y.isAllDayEvent()?[y.getAllDayStartDate(),new Date(y.getAllDayEndDate().getTime()-1)]:[y.getStartTime(),y.getEndTime()],
  id:y.getId()
}),
today=()=>cals.flatMap(x=>x.getEventsForDay(t).map(y=>fmt(y,x))),//カレンダーからイベント取得
eid=id=>cals.flatMap((x,y)=>(y=x.getEventById(id),y?[fmt(y,x)]:[]))[0],//idからイベント取得

widget=x=>({//discord embed形式
  color:parseInt(x.color.slice(1),16),//10進変換
  title:x.title,
  description:(x.desc?x.desc+'\n':'')+
    [...new Set(x.isAD? //Setで重複解除
      x.time.map(x=>x.toLocaleDateString(cfg.locale)):
      x.time.map(x=>x.toLocaleString(cfg.locale)))
    ].join(' ~ '),
  footer:{text:x.name}
}),

send=(x={username:'McbeEringi',avatar_url:'https://mcbeeringi.github.io/img/icon.png',content:'ﾆｬﾝ'})=> //webhook送信
  cfg.webhooks.forEach(y=>UrlFetchApp.fetch(y,{contentType:'application/json',method:'post',payload:JSON.stringify(x),muteHttpExceptions:false})),

main=()=>send({//一括通知
  username:'Google Calendar',
  avatar_url:'https://mcbeeringi.github.io/img/icon.png',
  content:'今日のイベント',
  embeds:today().map(widget)
}),
trg=()=>{//N分前個別通知
  const arr=prop.getProperty('ids').split(','),
    e=eid(arr[0]);//キューに入れたイベントの先頭一つを取得
  send({//usernameとcontentは通知に表示される
    username:`${Math.round((e.time[0].getTime()-t.getTime())/6e4)}分間待ってやる`,//イベントまでの分数を算出
    avatar_url:'https://mcbeeringi.github.io/img/icon.png',
    content:`${e.title} @${e.time[0].toLocaleString(cfg.locale)}`,
    embeds:[widget(e)]
  });
  prop.setProperty('ids',arr.slice(1).join(','));//キューの先頭を削除
},
daily=()=>{//mainとtrgの更新 毎日起動
  ScriptApp.getProjectTriggers().forEach(x=>'main,trg'.includes(x.getHandlerFunction())&&ScriptApp.deleteTrigger(x));//過去のトリガーを削除
  const set=(x,y)=>ScriptApp.newTrigger(y).timeBased().at(x).create();
  
  {//main
    const t1=new Date(t.toDateString()+' '+cfg.main);
    t1.getTime()>t.getTime()&&set(t1,'main');
  }
  prop.setProperty(//trg
    'ids',
    today().flatMap(x=>{
      const t1=new Date(x.time[0]-cfg.trg*6e4);
      if(x.isAD||!(t1.getTime()>t.getTime()))return[];//終日イベントと過去イベントは除外
      set(t1,'trg');return[[t1.getTime(),x.id]];
    }).sort((a,b)=>Math.sign(a[0]-b[0])).map(x=>x[1]).join(',')//イベントの開始時刻順に並べてキューに入れる
  );
},

sync_init=()=>{//APIキー再取得　必要時に手動で起動
  cals.forEach(x=>{
    const id=x.getId();
    prop.setProperty(`nst_${id}`,Calendar.Events.list(id,cfg.opt).nextSyncToken);
  });
},
sync=(e={calendarID:'example.calendar@gmail.com'})=>{//カレンダーdiff取得 カレンダー変更時呼び出し
  const w=Calendar.Events.list(e.calendarId,{...cfg.opt,syncToken:prop.getProperty(`nst_${e.calendarId}`)});
  prop.setProperty(`nst_${e.calendarId}`,w.nextSyncToken);
  w.items.length&&send({
    username:'Google Calendar',
    avatar_url:'https://mcbeeringi.github.io/img/icon.png',
    content:`変更されたイベント`,
    embeds:w.items.map(x=>({...widget(eid(x.id)),fields:[
      {name:'操作',value:{confirmed:Date.parse(x.updated)-Date.parse(x.created)<5e3?'追加':'変更',tentative:'暫定',cancelled:'削除'}[x.status]}
    ]}))
  });
  daily();//trgの更新
};
```
# GASの設定
- サービス+ > GoogleCalenderAPI の追加
- トリガーの設定
  - 日付ベースのタイマー 0~1時 daily()
  - カレンダーから カレンダー更新済み sync()

# 動作
`main()`が一括通知
`trg()`が個別通知

`daily()`が走ると当日分の`main()`と`trg()`が所定の時間に走るようにトリガーが設定される
当日分以外の`main()`と`trg()`のトリガーは削除される

カレンダーを変更すると`sync()`が走り前回の情報取得からの差分をAPIに取りに行きそれを整形して送信する

# はまりポイント
- **`Calendar.Events.list(iCalId)`で`nextSyncToken`が返ってこない**
  代わりに`nextPageToken`が返ってきていたらイベントが多いことが原因
  オプションの`maxResults`を大きくすると解決する
  `Calendar.Events.list(iCalId,{maxResults:65536})`などで解決
- **`CalendarApp.getEventById(iCalId)`でメインのカレンダーのイベントしか取得できない**
  一度自分が所有しているカレンダーをすべて取得して
  それらすべてに対して`.getEventById(iCalId)`する必要がある
  つまり`CalendarApp.getAllOwnedCalendars().flatMap(x=>(x=x.getEventById(iCalId),x?[x]:[]))[0]`などで解決
- **`CaledarEvent.getAllDayEndDate().toLocaleDateString('ja-JP')`で表示した日付が一日遅れている**
  取得した`Date`が終了日の24:00であることが原因
  `new Date(CaledarEvent.getAllDayEndDate().getTime()-1).toLocaleDateString('ja-JP')`などで解決  
- **スクリプトプロパティに`[xxx@java-lang...`みたいなわけのわからん文字列が入る**
  配列を直接入れようとするとそうなる
  横着せずに`.join()`で解決
- **`CalendarEvent.getColor()`でStringな数字が返ってくる hexカラーコードはどこ?**
  返ってくる数字はこれ
  https://developers.google.com/apps-script/reference/calendar/event-color
  これの逆引きが欲しいわけだが無理そうだったので上記ページのCSSを見て配列に起こした
  上記コード`cfg.col`を参照
- **`Calendar.Events.list().items[]`の`status`で追加か変更かの区別ができない**
  `updated`と`created`の差をとって数秒以内なら追加判定にする(雑)

# Gmailも
https://qiita.com/McbeEringi/items/27e22bbc0e246138fa3b