Compare commits

...

5 Commits

16 changed files with 128 additions and 305 deletions

BIN
bun.lockb

Binary file not shown.

@ -13,38 +13,40 @@
},
"devDependencies": {
"@sveltejs/enhanced-img": "^0.3.10",
"@sveltejs/kit": "^2.15.1",
"@sveltejs/kit": "^2.17.1",
"@sveltejs/vite-plugin-svelte": "^3.1.2",
"@tailwindcss/forms": "^0.5.9",
"@tailwindcss/typography": "^0.5.15",
"@tailwindcss/forms": "^0.5.10",
"@tailwindcss/typography": "^0.5.16",
"@types/eslint": "^9.6.1",
"@types/node": "^22.10.3",
"@types/node": "^22.13.1",
"autoprefixer": "^10.4.20",
"eslint": "^9.17.0",
"eslint": "^9.19.0",
"eslint-config-prettier": "^9.1.0",
"eslint-plugin-svelte": "^2.46.1",
"globals": "^15.14.0",
"mdsvex": "^0.12.3",
"postcss": "^8.4.49",
"postcss": "^8.5.1",
"prettier": "^3.4.2",
"prettier-plugin-svelte": "^3.3.2",
"prettier-plugin-svelte": "^3.3.3",
"svelte": "^4.2.19",
"svelte-adapter-bun": "^0.5.2",
"svelte-check": "^3.8.6",
"sveltekit-rate-limiter": "^0.6.1",
"tailwindcss": "^3.4.17",
"tslib": "^2.8.1",
"typescript": "^5.7.2",
"typescript-eslint": "^8.19.0",
"vite": "^5.4.11"
"typescript": "^5.7.3",
"typescript-eslint": "^8.23.0",
"vite": "^5.4.14"
},
"type": "module",
"dependencies": {
"@neodrag/svelte": "^2.2.0",
"@neodrag/svelte": "^2.3.0",
"@skyware/bot": "^0.3.8",
"@std/toml": "npm:@jsr/std__toml",
"@types/node-schedule": "^2.1.7",
"base64url": "^3.0.1",
"nanoid": "^5.0.9",
"node-schedule": "^2.1.1",
"rehype-autolink-headings": "^7.1.0",
"rehype-slug": "^6.0.0",
"robots-parser": "^3.0.1",

@ -1,9 +1,32 @@
<script context="module" lang="ts">
import type { Post } from "@skyware/bot";
export interface OutgoingLink {
name: string,
link: string,
}
export interface NoteData {
content: string,
published: number,
hasMedia: boolean,
hasQuote: boolean,
outgoingLinks?: OutgoingLink[],
}
export const noteFromBskyPost = (post: Post): NoteData => {
return {
content: post.text,
published: post.createdAt.getTime(),
outgoingLinks: [{ name: "bsky", link: post.uri }],
hasMedia: (post.embed?.isImages() || post.embed?.isVideo()) ?? false,
hasQuote: post.embed?.isRecord() ?? false,
}
}
</script>
<script lang="ts">
import type { Note } from "$lib/notes";
import Token from "./token.svelte";
export let id: string;
export let note: Note;
export let note: NoteData;
export let isHighlighted = false;
export let onlyContent = false;
@ -19,10 +42,7 @@
const getOutgoingLink = (name: string, link: string) => {
if (name === "bsky") {
if (link.startsWith("https://bsky.gaze.systems")) {
return link
}
return `https://bsky.gaze.systems/post/${link.split('/').pop()}`
return `https://bsky.app/profile/gaze.systems/post/${link.split('/').pop()}`
}
return link
}
@ -36,9 +56,11 @@
</script>
<div class="text-wrap break-words max-w-[70ch] leading-none">
{#if !onlyContent}<Token v={renderDate(note.published)} small={!isHighlighted}/> <Token v={id} keywd small={!isHighlighted}/><Token v="#" punct/>&nbsp;&nbsp;{/if}<Token v={note.content} str/>
{#if !onlyContent}<Token v={renderDate(note.published)} small={!isHighlighted}/> {/if}<Token v={note.content} str/>
{#if note.hasMedia}<Token v="-contains media-" keywd small/>{/if}
{#if note.hasQuote}<Token v="-contains quote-" keywd small/>{/if}
{#each note.outgoingLinks ?? [] as {name, link}}
{@const color = outgoingLinkColors[name]}
<span class="text-sm"><Token v="(" punct/><a style="color: {color};{getTextShadowStyle(color)}" href={getOutgoingLink(name, link)}>{name}</a><Token v=")" punct/></span>
<span class="text-sm"><Token v="(" punct/><a class="hover:motion-safe:animate-squiggle hover:underline" style="color: {color};{getTextShadowStyle(color)}" href={getOutgoingLink(name, link)}>{name}</a><Token v=")" punct/></span>
{/each}
</div>

17
src/hooks.server.ts Normal file

@ -0,0 +1,17 @@
import { updateLastPosts } from '$lib/bluesky';
import { lastFmUpdateNowPlaying } from '$lib/lastfm';
import { steamUpdateNowPlaying } from '$lib/steam'
import { cancelJob, scheduleJob, scheduledJobs } from 'node-schedule'
const UPDATE_LAST_JOB_NAME = "update steam game, lastfm track, bsky posts"
if (UPDATE_LAST_JOB_NAME in scheduledJobs) {
console.log(`${UPDATE_LAST_JOB_NAME} is already running, cancelling so we can start a new one`)
cancelJob(UPDATE_LAST_JOB_NAME)
}
console.log(`starting ${UPDATE_LAST_JOB_NAME} job...`);
scheduleJob(UPDATE_LAST_JOB_NAME, "*/1 * * * *", async () => {
console.log(`running ${UPDATE_LAST_JOB_NAME} job...`)
await Promise.all([steamUpdateNowPlaying(), lastFmUpdateNowPlaying(), updateLastPosts()])
}).invoke() // invoke once immediately

@ -1,5 +1,5 @@
import { env } from '$env/dynamic/private'
import { Bot } from "@skyware/bot";
import { Bot, Post } from "@skyware/bot";
import { get, writable } from 'svelte/store'
const bskyClient = writable<null | Bot>(null)
@ -17,4 +17,28 @@ const loginToBsky = async () => {
const bot = new Bot({ service: "https://bsky.social" })
await bot.login({ identifier: 'gaze.systems', password: env.BSKY_PASSWORD ?? "" })
return bot
}
}
export const getUserPosts = async (did: string, includeReposts: boolean = false, count: number = 10) => {
const client = await getBskyClient()
let feedCursor = undefined;
let posts: Post[] = []
// fetch requested amount of posts
while (posts.length < count || feedCursor === undefined) {
let feedData = await client.getUserPosts(
did, { limit: count, filter: 'posts_no_replies', cursor: feedCursor }
)
posts.push(...feedData.posts.filter((post) => !includeReposts && post.author.did === did))
feedCursor = feedData.cursor
}
return posts
}
const lastPosts = writable<Post[]>([])
export const updateLastPosts = async () => {
const posts = await getUserPosts("did:plc:dfl62fgb7wtjj3fcbb72naae", false, 13)
lastPosts.set(posts)
}
export const getLastPosts = () => { return get(lastPosts) }

@ -1,17 +1,11 @@
import { get, writable } from "svelte/store"
const GET_RECENT_TRACKS_ENDPOINT = "https://ws.audioscrobbler.com/2.0/?method=user.getrecenttracks&user=yusdacra&api_key=da1911d405b5b37383e200b8f36ee9ec&format=json&limit=1"
const CACHE_EXPIRY_SECONDS = 10
type LastTrack = {name: string, artist: string, image: string | null, link: string}
type CachedLastTrack = {track: LastTrack | null, since: number}
const cachedLastTrack = writable<CachedLastTrack>({track: null, since: 0})
const lastTrack = writable<LastTrack | null>(null)
export const lastFmGetNowPlaying: () => Promise<LastTrack | null> = async () => {
var cached = get(cachedLastTrack)
if (Date.now() - cached.since < CACHE_EXPIRY_SECONDS * 1000) {
return cached.track
}
export const lastFmUpdateNowPlaying = async () => {
try {
var resp = await (await fetch(GET_RECENT_TRACKS_ENDPOINT)).json()
var track = resp.recenttracks.track[0] ?? null
@ -24,11 +18,11 @@ export const lastFmGetNowPlaying: () => Promise<LastTrack | null> = async () =>
image: track.image[2]['#text'] ?? null,
link: track.url,
}
cachedLastTrack.set({track: data, since: Date.now()})
return data
lastTrack.set(data)
} catch(why) {
console.log("could not fetch last fm: ", why)
cachedLastTrack.set({track: null, since: Date.now()})
return null
lastTrack.set(null)
}
}
}
export const getNowPlaying = () => { return get(lastTrack) }

@ -1,68 +0,0 @@
import { existsSync, readFileSync, writeFileSync } from 'fs'
import { nanoid } from 'nanoid'
import { env } from '$env/dynamic/private'
export interface OutgoingLinkData {
name: string,
link: string,
}
export interface Note {
content: string,
published: number,
outgoingLinks?: OutgoingLinkData[],
replyTo?: NoteId,
}
type NoteId = string
export const notesFolder = `${env.WEBSITE_DATA_DIR}/note`
export const notesListFile = `${env.WEBSITE_DATA_DIR}/notes`
export const noteIdLength = 8;
export const getNotePath = (id: NoteId) => { return `${notesFolder}/${id}` }
export const genNoteId = () => {
let id = nanoid(noteIdLength)
while (existsSync(getNotePath(id))) {
id = nanoid(noteIdLength)
}
return id
}
export const noteExists = (id: NoteId) => { return existsSync(getNotePath(id)) }
export const readNote = (id: NoteId): Note => {
return JSON.parse(readFileSync(getNotePath(id)).toString())
}
export const findReplyRoot = (id: NoteId): {rootNote: Note, rootNoteId: NoteId} => {
let noteId: string | null = id
let current: {rootNote?: Note, rootNoteId?: NoteId} = {}
while (noteId !== null) {
current.rootNote = readNote(noteId)
current.rootNoteId = noteId
noteId = current.rootNote.replyTo ?? null
}
if (current.rootNote === undefined || current.rootNoteId === undefined) {
throw "no note with id found"
}
return {
rootNote: current.rootNote,
rootNoteId: current.rootNoteId,
}
}
export const writeNote = (id: NoteId, note: Note) => {
writeFileSync(getNotePath(id), JSON.stringify(note))
// only append to note list if its not in it yet
let noteList = readNotesList()
if (noteList.indexOf(id) === -1) {
writeNotesList([id].concat(noteList))
}
}
export const createNote = (id: NoteId, note: Note) => {
writeNote(id, note)
return id
}
export const readNotesList = (): NoteId[] => {
return JSON.parse(readFileSync(notesListFile).toString())
}
export const writeNotesList = (note_ids: NoteId[]) => {
writeFileSync(notesListFile, JSON.stringify(note_ids))
}

@ -4,42 +4,36 @@ import { get, writable } from "svelte/store";
const STEAM_ID = "76561198106829949"
const GET_PLAYER_SUMMARY_ENDPOINT = `http://api.steampowered.com/ISteamUser/GetPlayerSummaries/v0002/?key=${env.STEAM_API_KEY}&steamids=${STEAM_ID}&format=json`
const CACHE_EXPIRY_SECONDS = 10
type LastGame = {name: string, link: string, icon: string, pfp: string}
type CachedLastGame = {game: LastGame | null, since: number}
const steamgriddbClient = writable<SGDB | null>(null);
const cachedLastGame = writable<CachedLastGame>({game: null, since: 0})
const steamgriddbClient = writable<SGDB | null>(null)
const lastGame = writable<LastGame | null>(null)
export const steamGetNowPlaying: () => Promise<LastGame | null> = async () => {
export const steamUpdateNowPlaying = async () => {
var griddbClient = get(steamgriddbClient)
if (griddbClient === null) {
griddbClient = new SGDB(env.STEAMGRIDDB_API_KEY)
steamgriddbClient.set(griddbClient)
}
var cached = get(cachedLastGame)
if (Date.now() - cached.since < CACHE_EXPIRY_SECONDS * 1000) {
return cached.game
}
try {
var profile = (await (await fetch(GET_PLAYER_SUMMARY_ENDPOINT)).json()).response.players[0]
if (!profile.gameid) {
throw "no game is being played"
}
var icons = await griddbClient.getIconsBySteamAppId(profile.gameid, ['official'])
console.log(icons)
var game = {
//console.log(icons)
var game: LastGame = {
name: profile.gameextrainfo,
link: `https://store.steampowered.com/app/${profile.gameid}`,
icon: icons[0].thumb.toString(),
pfp: profile.avatarmedium,
}
cachedLastGame.set({game, since: Date.now()})
return game
lastGame.set(game)
} catch(why) {
console.log("could not fetch steam: ", why)
cachedLastGame.set({game: null, since: Date.now()})
return null
lastGame.set(null)
}
}
}
export const getLastGame = () => { return get(lastGame) }

@ -178,7 +178,7 @@
rss:
<a class="align-middle hover:underline" href="/entries/_rss">posts</a>
/
<a class="align-middle hover:underline" href="/log/_rss">log</a>
<a class="align-middle hover:underline" href="https://bsky.app/profile/did:plc:dfl62fgb7wtjj3fcbb72naae/rss">log</a>
</div>
{/if}
</div>

@ -1,18 +1,19 @@
import { lastFmGetNowPlaying } from "$lib/lastfm"
import { readNote, readNotesList } from "$lib/notes.js"
import { steamGetNowPlaying } from "$lib/steam"
import { getLastPosts } from "$lib/bluesky.js"
import { getNowPlaying } from "$lib/lastfm"
import { getLastGame } from "$lib/steam"
import { noteFromBskyPost } from "../components/note.svelte"
export const load = async ({}) => {
const lastTrack = await lastFmGetNowPlaying()
const lastGame = await steamGetNowPlaying()
const lastTrack = getNowPlaying()
const lastGame = getLastGame()
const lastPosts = getLastPosts()
const lastNote = lastPosts.length > 0 ? noteFromBskyPost(lastPosts[0]) : null
let banners: number[] = []
while (banners.length < 3) {
const no = getBannerNo(banners)
banners.push(no)
}
const lastNoteId = readNotesList()[0]
const lastNote = readNote(lastNoteId)
return {banners, lastTrack, lastGame, lastNote, lastNoteId}
return {banners, lastTrack, lastGame, lastNote}
}
const getBannerNo = (others: number[]) => {

@ -146,6 +146,7 @@
</div>
</Window>
<Window title="status" style="mt-auto" removePadding>
{#if data.lastNote}
<div class="m-1.5 flex flex-col font-monospace">
<div
class="prose prose-ralsei items-center p-1 border-4 text-sm font-bold bg-ralsei-black"
@ -155,9 +156,10 @@
<span class="border-4 pl-[1ch]" style="border-style: none none none double;">published on {renderDate(data.lastNote.published)}</span>
</div>
<div class="mt-0 p-1 border-4 border-double bg-ralsei-black min-w-full max-w-[40ch]">
<Note id={data.lastNoteId} note={data.lastNote} onlyContent/>
<Note note={data.lastNote} onlyContent/>
</div>
</div>
{/if}
{#if data.lastTrack}
<div class="flex flex-row m-1.5 border-4 border-double bg-ralsei-black">
<!-- svelte-ignore a11y-missing-attribute -->

@ -1,15 +1,3 @@
import {_load as load_logs} from '../log/+page.server.ts'
export const load = (params) => {
var url = params.url
var log_id = url.searchParams.get("log_id")
if (log_id !== null) {
url.searchParams.append("id", log_id)
}
var log_page = url.searchParams.get("log_page")
if (log_page !== null) {
url.searchParams.append("page", log_page)
}
var logs_result = load_logs({url})
return logs_result
}
export const load = load_logs

@ -1,41 +1,12 @@
import { noteExists, readNote, readNotesList } from '$lib/notes'
import { getLastPosts, getUserPosts } from '$lib/bluesky.js';
import { noteFromBskyPost } from '../../components/note.svelte';
const notesPerPage: number = 15
export const load = ({ url }) => {
return _load({ url })
export const load = async ({ }) => {
return _load()
}
export const _load = ({ url }: { url: URL }) => {
// get the note id to search for and display the page it is in
const noteId = url.searchParams.get("id")
// get the page no if one is provided, otherwise default to 1
let page = parseInt(url.searchParams.get("page") || "1")
if (isNaN(page)) { page = 1 }
// calculate page count
const notesList = readNotesList()
const pageCount = Math.ceil(notesList.length / notesPerPage)
// find what page the note id if supplied is from
if (noteId !== null && noteExists(noteId)) {
const noteIndex = notesList.lastIndexOf(noteId)
if (noteIndex > -1) {
page = Math.floor(noteIndex / notesPerPage) + 1
}
export const _load = async () => {
return {
feedPosts: getLastPosts().map(noteFromBskyPost),
}
// clamp page between our min and max
page = Math.min(page, pageCount)
page = Math.max(page, 1)
// get the notes from the chosen page
const notes = new Map(
notesList.slice((page - 1) * notesPerPage, page * notesPerPage)
.map(
(id) => { return [id, readNote(id)] }
)
)
return { notes, highlightedNote: noteId, page }
}

@ -4,17 +4,8 @@
import Note from '../../components/note.svelte';
export let data;
const highlightedNote = data.notes.get(data.highlightedNote ?? '') ?? null
</script>
<svelte:head>
{#if highlightedNote !== null}
<meta property="og:description" content={highlightedNote.content} />
<meta property="og:title" content="log #{data.highlightedNote}" />
{/if}
</svelte:head>
<Window title="terminal" removePadding>
<div
class="
@ -29,10 +20,9 @@
<Token v="[" punct/>gazesystems <Token v="/" keywd/><Token v="]$" punct/> <Token v="ls" funct/> <Token v="log" /> <Token v="|" punct/> <Token v="each" funct/> <Token v="&#123;" punct/><Token v="|" punct/><Token v="file"/><Token v="|" punct/> <Token v="render" funct/> <Token v="(" punct/><Token v="open" funct/> <Token v="$file.name" /><Token v=")" punct/><Token v="&#125;" punct/>
<br>
<br>
{#each data.notes as [id, note], index}
{@const isHighlighted = id === data.highlightedNote}
<Note {id} {note} {isHighlighted}/>
{#if index < data.notes.size - 1}
{#each data.feedPosts as note, index}
<Note {note}/>
{#if index < data.feedPosts.length - 1}
<div class="mt-3"/>
{/if}
{/each}

@ -1,38 +0,0 @@
import { PUBLIC_BASE_URL } from '$env/static/public';
import { readNote, readNotesList, type Note } from '$lib/notes.ts';
const logUrl = `${PUBLIC_BASE_URL}/log`;
interface NoteData {
data: Note,
id: string,
}
export const GET = async ({ }) => {
const log = readNotesList().map((id) => {return { data: readNote(id), id }})
return new Response(
render(log),
{
headers: {
'content-type': 'application/xml',
'cache-control': 'no-store',
}
})
};
const render = (log: NoteData[]) => `<?xml version="1.0" encoding="UTF-8" ?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
<channel>
<atom:link href="${logUrl}/_rss" rel="self" type="application/rss+xml" />
<title>dusk's notes (@gaze.systems)</title>
<link>${logUrl}</link>
<description>a collection of random notes i write whenever, aka my microblogging spot</description>
${log.map((note) => `<item>
<guid>${logUrl}/?id=${note.id}</guid>
<link>${logUrl}/?id=${note.id}</link>
<description>${note.data.content}</description>
<pubDate>${new Date(note.data.published).toUTCString()}</pubDate>
</item>`).join('')}
</channel>
</rss>
`;

@ -1,76 +0,0 @@
import { env } from '$env/dynamic/private';
import { PUBLIC_BASE_URL } from '$env/static/public';
import { getBskyClient } from '$lib/bluesky.js';
import { createNote, findReplyRoot, genNoteId, readNote, type Note } from '$lib/notes';
import type { Post, PostPayload, PostReference, ReplyRef } from '@skyware/bot';
interface NoteData {
content: string,
replyTo?: string,
embedUri?: string,
bskyPosse: boolean,
}
export const POST = async ({ request }) => {
const token = request.headers.get('authorization')
if (token !== env.GAZEBOT_TOKEN) {
return new Response("rizz failed", { status: 403 })
}
// get id
const noteId = genNoteId()
// get note data
const noteData: NoteData = await request.json()
console.log(`want to create note #${noteId} with data: `, noteData)
// get a date before we start publishing to other platforms
let note: Note = {
content: noteData.content,
published: Date.now(),
outgoingLinks: [],
replyTo: noteData.replyTo,
}
let errors: string[] = []
let repliedNote: Note | null = null
if (noteData.replyTo !== undefined) {
repliedNote = readNote(noteData.replyTo)
}
// bridge to bsky if want to bridge
if (noteData.bskyPosse) {
const postContent = `${noteData.content} (${PUBLIC_BASE_URL}/log?id=${noteId})`
try {
const bot = await getBskyClient()
let postPayload: PostPayload = {
text: postContent,
createdAt: new Date(note.published),
external: noteData.embedUri,
}
let postRef: PostReference
// find parent and reply posts
let replyRef: ReplyRef | null = null
if (noteData.replyTo !== undefined && repliedNote !== null) {
const getBskyUri = (note: Note) => { return note.outgoingLinks?.find((v) => {return v.name === "bsky"})?.link }
const parentUri = getBskyUri(repliedNote)
if (parentUri !== undefined) {
const parentPost = await bot.getPost(parentUri)
postRef = await parentPost.reply(postPayload)
} else {
throw "a reply was requested but no reply is found"
}
} else {
postRef = await bot.post(postPayload)
}
note.outgoingLinks?.push({name: "bsky", link: postRef.uri})
} catch(why) {
console.log(`failed to post note #${noteId} to bsky: `, why)
errors.push(`error while posting to bsky: ${why}`)
}
}
// create note (this should never fail otherwise it would defeat the whole purpose lol)
createNote(noteId, note)
// send back created note id and any errors that occurred
return new Response(JSON.stringify({ noteId, errors }), {
headers: {
'content-type': 'application/json',
'cache-control': 'no-store',
}
})
};