이벤트 전날, 디스코드에 구글 캘린더 토대로 D-1 메세지 띄워주기

소개

제가 속해있는 커뮤니티에서는 공용 구글 캘린터에 이벤트를 업데이트 하는데요, 이벤트가 열리기 전날 D-1 알림 메세지를 디스코드에 자동으로 띄워주면 편리할 것 같다는 생각이 들었습니다.

아직 미완성입니다. N8N는 오늘 가입했고, GPT에게 방법을 물어가면서 해보고 있는데, 아직 제대로 구현되지는 않았습니다. 스터디 시간이든.. 질문을 하기 위한 용도로 과정을 메모해 둡니다.

진행 방법

어떤 도구를 사용했고, 어떻게 활용하셨나요?

  • N8N

  • Google calendar

  • Discord

닿님의 메세지를 힌트 삼아, 방법을 찾아가봤어요.

닿님 왈 : "'scheduler'라는 트리거 노드가 있어요!! 1일 단위로 매일 트리거 되게끔 설정하시고, 현재 날짜와 이벤트 날짜를 비교해서 1일 후인 이벤트가 있는 경우만 실행시키도록요"

N8n에서 노드를 구성하기 전, 우선 준비물이 있는데요.

  • 구글 캘린더에서의 iCal 주소 & Discord에서의 웹 훅입니다. Discord는 우선 커뮤니티에서 사용중인 것 말고, 따로 서버를 만들어두었어요.

  • 저는 완전 초심자여서, 제가 따로 방법을 구상하기보다 그냥 GPT가 알려주는 방법을 따라해 보려고 합니다.


GPT가 알려준 방법

한국 웹 사이트의 스크린 샷
4 TFTP 요청 ICS7 기능
const raw = $json.body || $json.data || $json || '';
const ics = typeof raw === 'string' ? raw : JSON.stringify(raw);

// 1) 줄 접힘 해제
const unfolded = ics
  .split(/\r?\n/)
  .reduce((acc, line) => {
    if (line.startsWith(' ') || line.startsWith('\t')) acc[acc.length - 1] += line.slice(1);
    else acc.push(line);
    return acc;
  }, [])
  .join('\n');

// 2) VEVENT 블록 추출
const events = [];
let cur = null;
for (const line of unfolded.split('\n')) {
  if (line === 'BEGIN:VEVENT') cur = [];
  else if (line === 'END:VEVENT') { if (cur) events.push(cur.join('\n')); cur = null; }
  else if (cur) cur.push(line);
}

// 유틸
function parseKV(line) {
  const [left, valueRaw] = line.split(':');
  const value = (valueRaw || '').trim();
  const [prop, ...params] = left.split(';');
  const key = prop.toUpperCase();
  const pmap = {};
  for (const p of params) { const [k,v] = p.split('='); if (k && v) pmap[k.toUpperCase()] = v; }
  return { key, params: pmap, value };
}
function toKSTDateStr(d) {
  return new Intl.DateTimeFormat('en-CA', { timeZone: 'Asia/Seoul', year: 'numeric', month: '2-digit', day: '2-digit' }).format(d);
}
function toKSTDateTimeStr(d) {
  const ymd = toKSTDateStr(d);
  const hm = new Intl.DateTimeFormat('en-GB', { timeZone: 'Asia/Seoul', hour: '2-digit', minute: '2-digit', hour12: false }).format(d);
  return `${ymd} ${hm}`;
}
function parseDate({ value, tzid }) {
  // 종일: YYYYMMDD
  if (/^\d{8}$/.test(value)) {
    const y = +value.slice(0,4), m = +value.slice(4,6)-1, d = +value.slice(6,8);
    // KST 00:00 을 UTC로 저장 (KST -9h)
    return new Date(Date.UTC(y, m, d, -9, 0, 0));
  }
  // UTC: ...Z
  if (value.endsWith('Z')) {
    const iso = value.replace(/^(\d{4})(\d{2})(\d{2})T(\d{2})(\d{2})(\d{2})Z$/, '$1-$2-$3T$4:$5:$6Z');
    return new Date(iso);
  }
  // 로컬시간 (+TZID)
  const y = +value.slice(0,4), m = +value.slice(4,6)-1, d = +value.slice(6,8);
  const hh = +value.slice(9,11), mm = +value.slice(11,13), ss = +value.slice(13,15);
  const isKST = tzid && tzid.toLowerCase() === 'asia/seoul';
  return new Date(Date.UTC(y, m, d, (isKST ? hh-9 : hh-9), mm, ss));
}

// 3) 필드 추출
const out = [];
for (const block of events) {
  let title='', description='', location='', url='', organizer='';
  let start=null, end=null;
  for (const ln of block.split('\n')) {
    if (!ln.includes(':')) continue;
    const { key, params, value } = parseKV(ln);
    if (key==='SUMMARY') title = value;
    else if (key==='DESCRIPTION') description = value;
    else if (key==='LOCATION') location = value;
    else if (key==='URL') url = value;
    else if (key==='ORGANIZER') organizer = value;
    else if (key==='DTSTART') start = parseDate({ value, tzid: params.TZID });
    else if (key==='DTEND') end = parseDate({ value, tzid: params.TZID });
  }
  if (!start) continue;
  out.push({
    json: {
      title: title || '(제목 없음)',
      description, location, url, organizer,
      start_utc: start.toISOString(),
      end_utc: end ? end.toISOString() : null,
      start_kst_text: toKSTDateTimeStr(start),
      end_kst_text: end ? toKSTDateTimeStr(end) : null,
      start_kst_date: toKSTDateStr(start), // YYYY-MM-DD (내일 비교용)
    }
  });
}
return out;

5) Function – “내일 일정만” 필터

  • Node: Function

  • Name: Filter Tomorrow (KST)

  • Code

function kstYYYYMMDD(date) {
  return new Intl.DateTimeFormat('en-CA', {
    timeZone: 'Asia/Seoul', year: 'numeric', month: '2-digit', day: '2-digit'
  }).format(date);
}
// 오늘(KST)
const now = new Date();
const todayKst = kstYYYYMMDD(now);

// 내일(KST) 구하기: 오늘 00:00 KST를 기준으로 +1일
const [y,m,d] = todayKst.split('-').map(Number);
const base = new Date(Date.UTC(y, m-1, d, 15, 0, 0)); // KST 00:00 == 전날 15:00 UTC
base.setUTCDate(base.getUTCDate() + 1);
const tomorrowKst = kstYYYYMMDD(base);

// 필터
return items.filter(it => it.json.start_kst_date === tomorrowKst);

6) Function – 디스코드 메시지 만들기

  • Node: Function

  • Name: Build Discord Message

  • Code (간단: 일정별 1메시지씩 전송)

if (items.length === 0) return [];

return items.map((it) => {
  const { title, start_kst_text, end_kst_text, location, url } = it.json;
  const lines = [
    '📅 **내일 일정 리마인드**',
    `제목: ${title}`,
    `시간: ${start_kst_text}${end_kst_text ? ' ~ ' + end_kst_text : ''}`,
  ];
  if (location) lines.push(`장소: ${location}`);
  if (url) lines.push(`링크: ${url}`);
  return { json: { content: lines.join('\n') } };
});

7) HTTP Request – Discord Webhook 전송

  • Node: HTTP Request

  • Method: POST

  • URL: (디스코드 Webhook URL)

  • Headers: Content-Type: application/json

  • Send Body: JSON

  • JSON/RAW Parameters: ON

  • Body: Expression{{$json}}

위 단계에서 { content: "…" } 를 만들어뒀으니 그대로 보냅니다.
아이템이 N개면 N번 호출되어 일정별 메시지가 올라갑니다.



Q. Response Format을 String으로 설정한다는 건 뭘까,,

Q. JSON/RAW Parameters를 ON으로 해두는 버튼은 어디에 있는거지,,

제대로 따라한 건지는 모르겠지만, 일단 뭔가 만들어서 테스트해 봤는데요,

iPad에서 중국어 앱의 스크린 샷

구글 캘린더상에 내일 일정이 있는데도 왜 안나오는지 모르겠습니다 ㅠ-ㅠ

캘린더 알림 - 스크린 샷 썸네일

결과와 배운 점

시행착오

  • 우선 저는,,, 기존의 예제를 따라해 보면서 기능 + 왜 이걸 하는지를 익혀야 할 타이밍이 아닐까 싶었습니다. GPT가 알려준대로 따라서 넣어봐도 이걸 여기에 넣는게 맞나.. 헷갈리는 단계라서요..@@

(기타) 온보딩 관련 고민

멤버 온보딩 경험 설계에 대해 스터디 때 이야기를 나눠보기로 했는데요, 저는 처음 온보딩 경험을 설계하는 시점이라면 다음과 같은 지점을 고려/질문하는 게 중요한 것 같아요. 아이데이션하듯 적어봤습니다.

(1) 새로운 구성원이 커뮤니티에 소속될 때, 얻고자 하는 가치/경험에 대한 기대 확인 (2) 새로운 구성원이 적응해야 할 것은 무엇인지 생각해보기 (3) 모르는 것, 어려운 점이 있을 때 어떤 채널을 통해 정보를 얻게 할지 (4) 기존/같은 시기에 합류한 구성원이 소속감, 커뮤니티 활동에서 가치를 느낀 아하 포인트는 무엇인지 (5) 어떤 시기에 어떤 정보가 안내하는 게 제대로 인지하는 데 도움이 될지 (6) 특별히 선발/초대한 커뮤니티 구성원이라면 그 이유를 설명하면서 환영 받는 느낌을 주기

1
1개의 답글

뉴스레터 무료 구독

👉 이 게시글도 읽어보세요