Robojuletre

Da var årets Advent of Code akkurat over. For noen er denne programmeringskonkurransen et viktig aspekt av adventstiden. Les om hvordan man kan lage en bot som poster topplisten til Slack hver dag!

Advent of Code

Dette er en programmeringskonkurranse hvor man får to oppgaver servert hver dag i desember frem til julaften. Oppgavene er av varierende vanskligshetsgrad, fra det trivielle til svært tidkrevende.

Jeg kan anbefale denne presentasjonen om historien back Advent of Code.

Personlig syns jeg det er morsomt å løse oppgavene. Det er også en utmerket måte å forbedre programmeringsferdighetene dine på, samt en flott arena for å lære nye programmeringsspråk.

Hva skal vi lage?

Advent of Code lar deg sette opp en privat toppliste. Når du lager en egen liste så får du en kode som du kan dele med andre slik at de kan bli med. Du kan være med på mange lister om du vil.

Å sjekke listen krever at man går til Advent of Code-siden, og det kan jo være litt slitsomt.

På prosjektet jeg er på om dagen så hadde en av utviklerene, Kristiane Westgård, laget sin egen Slackbot som postet til Slack hver dag klokken 12. Det syns jeg var en kjempegod idé, og det hun hadde laget inspirerte meg til å lage en egen bot til Kodemaker-Slacken.

Teknologivalg

Denne oppgaven er så triviell at teknologivalg nesten ikke spiller noen rolle. Med andre ord så har vi masse rom for overengineering.

Dette er en flott mulighet til å prøve ut Deno, Ryan Dahl sitt nye prosjekt. Det var han som lagde Node, så det er spennende å grave i hva som er blitt bedre.

Deno støtter også cron-funksjonalitet ut av boksen, så da er det svært lett å styre når vi å sende nye toppliste-meldinger.

Hvordan hente topplisten

Robosnømann

Det første vi må gjøre er å få tak i topplisten fra Advent of Code. De tilbyr et API, hvor du kan hente data i form av JSON.

De ber deg pent om å ikke gjøre kall oftere enn hvert 15.minutt. APIet krever at du legger ved en session cookie. Den kan du kan fiske frem fra sesjonen din når du logger inn via nettleseren. En sesjon varer i en måned, så det passert fint med adventskalenderen.

For å hente data med Deno:

const response = await fetch("https://adventofcode.com/2023/leaderboard/private/view/193656.json", {
  "headers": {
    "accept": "application/json",
    "cookie": "EN COOKIE HENTET FRA SESJONEN DIN I NETTLESEREN",
  },
  "method": "GET"
});

Responsen inneholder følgende, representert som TyperScript-typer:

type TAoCLeaderBoard = {
  members: { [key: string]: TPlayer }
}

type TPlayer = {
  id: number,
  name: string,
  last_start_ts: number,
  stars: number,
  local_score: number
}

Merk at dette er en forenklet type. Vi får også flere detaljer som f.eks når hver enkelt spiller fikk klarte en oppgave. Det er informasjon vi ikke trenger her, så det holder vi utenfor.

Hvordan sende en melding til Slack

Robojulenisse

For å poste noe til Slack så må du ha tilgang til å opprette en Slack-app i et Slack-workspace.

Vi gjør følgende:

  • Gå til api.slack.com
  • Opprett en ny app
  • Velg Incoming webhooks
  • Opprett en ny webhook ved å velge hvilken kanal appen din skal poste til

Da får du en URL som du kan POSTe til. Lettere kan det ikke bli.

For å sende en melding blir det da:

curl -X POST 
-H 'Content-type: application/json' 
--data '{"text":"Hello, World!"}' 
https://hooks.slack.com/services/APPEN/SIN/HEMMELIG/VERDI

Selve URLen inneholder en hemmelighet. Om den kommer på avveie kan andre poste hva som helst på vegne av appen din. Til utvikling så kan det lurt å lage en egen webhook som sender en melding til deg selv, så slipper du spamme ned en kanal før appen din er klar.

I Deno vil det se slik ut:

const postMessageToSlack = async (slackUrl: string, msg: string) => {
  return await fetch(slackUrl, {
    method: "POST",
    headers: { "Content-Type": "application/json", },
    body: JSON.stringify({ text: msg })
  })
}

For å lage topplisten så må vi sortere deltakere basert på score og krydre med noen emojier.

const toSlackMessage = (result: TAoCLeaderBoard): string => {
  const title = "🎄 *Advent of Code i Kodemaker* 🎄";
  const ending = `Bare ${ daysTillXmas(new Date()) } dager igjen til jul!🎅🏻`

  const members = Object.entries(result.members)
    .map(([_, v]) => v)
    .sort((a, b) => b.local_score - a.local_score)
    .map((v, idx) => `${ idx + 1 }. ${ v.name }  *${ v.local_score }* poeng og *${ v.stars }* ⭐`)

  return [title, "", ...members, "", ending].join("\n")
}

Resultatet blir da noe i denne gaten:

🎄 Advent of Code i Kodemaker 🎄 
1. Julenissen  23 poeng og 9 ⭐
2. Pakke       16 poeng og 6 ⭐
3. Pepperkake  13 poeng og 9 ⭐️
Bare 12 dager igjen til jul! 🎅🏻

Hvordan trigge en ny melding

Deno støtter cron-uttrykk ut av boksen, så det er så lett som dette:

Deno.cron("Update AoC score on Slack", "0 11 * * *", { backoffSchedule: [2000, 5000] }, () => {
  postAoCTopplisteTilSlack();
});

backOffSchedule prøver funksjonen på nytt om noe feiler basert på en liste med millisekunder.

Hosting

Det er mange måter å hoste en Deno-løsning på. Forretningsideen bak Deno er å sluse folk til serverless-plattformen Deno Deploy, så la oss prøve ut den først som sist.

Roboreinsdyr

Hvis vi tar en rask titt på prislisten til Deno Deploy, så ser vi at det er gratis opp til 1 million requester i måneden.

Ved å poste en melding til Slack om dagen, så burde det bli omtrent 24 requests. Det blir ikke flere dager i desember, så dette burde holde i massevis.

Å sette opp et nytt prosjekt på Deno Deploy er fort gjort. Ved å hoste koden vår på Github, så er det bare å gi Deno Deploy tilgang til Github-repoet, så vil ny versjon bli publisert hver gang du pusher til main-branchen.

For å ikke sjekke inn AoC-cookien vår og Slack-hemmeligeheten vår, så legger vi det ved som environment-variabler.

Kode

Selve koden er under 100 linjer Typescript, så det kan du lese i sin helhet her:

type TPlayer = {
  id: number,
  name: string,
  last_start_ts: number,
  stars: number,
  local_score: number
}

type TAoCLeaderBoard = {
  members: { [key: string]: TPlayer }
}

const toSlackMessage = (result: TAoCLeaderBoard): string => {
  const title = "🎄 *Advent of Code i Kodemaker* 🎄";
  const ending = `Bare ${ daysTillXmas(new Date()) } dager igjen til jul!🎅🏻`

  const members = Object.entries(result.members)
    .map(([_, v]) => v)
    .sort((a, b) => b.local_score - a.local_score)
    .map((v, idx) => `${ idx + 1 }. ${ v.name }  *${ v.local_score }* poeng og *${ v.stars }* ⭐`)

  return [title, "", ...members, "", ending].join("\n")
}

const parseAoCLeaderboardResponse = (jsonBody: any) => {
  return {
    owner_id: jsonBody?.owner_id,
    members: jsonBody?.members
  } as TAoCLeaderBoard;
}

const getAoCLeaderboard = async (aocCookie: string) => {
  const response = await fetch("https://adventofcode.com/2023/leaderboard/private/view/193656.json", {
    "headers": {
      "accept": "application/json",
      "cookie": aocCookie,
    },
    "method": "GET"
  });

  if (response.status === 200) {
    return {
      data: parseAoCLeaderboardResponse(await response.json())
    }
  } else {
    throw new Error("Failed to parse AoC response");
  }
}

const postMessageToSlack = async (slackUrl: string, msg: string) => {
  return await fetch(slackUrl, {
    method: "POST",
    headers: { "Content-Type": "application/json", },
    body: JSON.stringify({ text: msg })
  })
}

// Dirty hack
const daysTillXmas = (timeNow: Date) => {
  const xmas: Date = new Date('2023-12-24');
  const diffInTime: number = xmas.getTime() - timeNow.getTime();
  const diffInDays: number = Math.ceil(diffInTime / (1000 * 3600 * 24));
  return diffInDays
}

const postAoCTopplisteTilSlack = async () => {
  const aocCookie = Deno.env.get("AOC_COOKIE");
  const slackSecret = Deno.env.get("AOC_SLACK_URL")

  if (!aocCookie || !slackSecret) {
    console.error("Error: AOC_COOKIE and AOC_SLACK_URL required")
    Deno.exit(1)
  }

  const result = await getAoCLeaderboard(aocCookie);

  if (result.data) {
    const msg = toSlackMessage(result.data);
    await postMessageToSlack(slackSecret, msg)
  }
}

Deno.cron("Update AoC score on Slack", "0 11 * * *", { backoffSchedule: [2000, 5000] }, () => {
  postAoCTopplisteTilSlack();
});

Her er det åpenbart rom for forbedring, men det er ikke verdens undergang om dette feiler.

Ideer til neste år

Denne botten ble satt sammen på i løpet av en kveld, så her er det rikelig med rom for forbedring.

Til neste år har jeg tenkt å:

  • Sjekk ved jevne mellomrom i løpet av dagen om det har skjedd noe siden sist
    • Det krever at vi lagrer unna forrige respons fra Advent of Code-APIet
  • Hvis det har skjedd noe, beskriv det
    • Hvem har tatt nye stjerner?
    • Har topplisten endret seg?
    • Mase på de som ikke har gjort noe den siste tiden
    • Har noen klart dagens oppgaver? Hvem løste de først?

Er Deno interessant?

Det er vanskelig å slå tiden det tar fra du lager et nytt Deno-prosjekt til du har en løsning kjørende i skyen. Hvis det er en viktig metrikk for deg så er det absolutt verdt å prøve ut Deno. Selvsagt, baksiden med slik serverless-teknologi er at det er kort vei til vendor lock-in og alt det medfører.

Til små hobbyprosjekt er dette hvertfall et alternativ å vurdere.