UNPKG

@struktoai/mirage-core

Version:

A Unified Virtual Filesystem For AI Agents

211 lines • 9.38 kB
// ========= Copyright 2026 @ Strukto.AI All Rights Reserved. ========= // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // ========= Copyright 2026 @ Strukto.AI All Rights Reserved. ========= import { FileChangeKind } from "../../types.js"; import { eventAt, field, textField } from "../../events/payload.js"; import { TEXT_CHANNEL_TYPES } from "./channels.js"; import { DiscordApiError } from "./client.js"; import { CHANNEL_EVENTS, CHAT_FILE, FILES_DIR, GUILD_EVENTS, MEMBER_EVENTS, MESSAGE_EVENTS, REACTION_EVENTS, THREAD_TYPES, } from "./constants.js"; import { channelDirname, guildDirname, snowflakeToDate } from "./entry.js"; import { fileBlobName, isListed } from "./files.js"; /** * The UTC day a message id was minted on, or null. * * Mirrors Python `_day_of` (`core/discord/events.py`). */ function dayOf(messageId) { if (!/^\d+$/.test(messageId)) return null; return snowflakeToDate(messageId); } /** * Map one Discord gateway dispatch onto mount paths. * * The consumer holds the gateway connection, and passes each dispatch's `t` * with its `d`. Nothing here runs a loop. * * A message is a row of `chat.jsonl` in the day its id was minted on, in UTC * as `readdir` buckets it, so an edit, a delete or a reaction refreshes the day * the message was posted, never today. Every directory carries a name, and a * dispatch names a guild and a channel by id only, so each id is resolved once * through `/guilds/<id>` or `/channels/<id>` and remembered; a channel or guild * dispatch forgets what it renamed. * * A thread is not listed, and its messages are in no day file. What changes is * the thread's starter message in its parent channel, which carries the * thread's counts and has the thread's id, so a message in a thread refreshes * the parent's day of the thread id. * * A new message's attachments are created at the names `readdir` gives them. * An edit or a delete does not say which attachments went with it, so that * day's `files` directory is UNKNOWN. A channel, member or guild dispatch * changes a listing's names, so it is UNKNOWN on the listing: `channels`, * `members`, or the whole mount for a guild, which also fires for every guild * when the gateway reconnects and so re-inventories what the gap missed; it * also forgets the guild's channels, since one renamed during the gap kept its * old name here. A direct message belongs to no guild and maps to nothing. * * Mirrors Python `DiscordEventHook` (`core/discord/events.py`). */ export class DiscordEventHook { accessor; guilds = new Map(); channels = new Map(); constructor(accessor) { this.accessor = accessor; } async get(endpoint) { const data = await this.accessor.transport.call('GET', endpoint); return data !== null && typeof data === 'object' && !Array.isArray(data) ? { ...data } : {}; } async guildDir(guildId) { let cached = this.guilds.get(guildId); if (cached === undefined) { let guild; try { guild = await this.get(`/guilds/${guildId}`); } catch (err) { if (!(err instanceof DiscordApiError) || err.status !== 404) throw err; console.debug(`discord events: guild ${guildId} is gone: ${err.message}`); return null; } cached = guildDirname({ ...guild, id: typeof guild.id === 'string' ? guild.id : guildId }); this.guilds.set(guildId, cached); } return cached; } async channel(channelId) { let cached = this.channels.get(channelId); if (cached === undefined) { let channel; try { channel = await this.get(`/channels/${channelId}`); } catch (err) { if (!(err instanceof DiscordApiError) || err.status !== 404) throw err; console.debug(`discord events: channel ${channelId} is gone: ${err.message}`); return null; } cached = { ...channel, id: typeof channel.id === 'string' ? channel.id : channelId }; this.channels.set(channelId, cached); } return cached; } async messages(root, eventType, payload, messageIds) { const channelId = textField(payload, 'channel_id'); if (channelId === null) return []; let channel = await this.channel(channelId); let ids = messageIds; let thread = false; if (channel?.type !== undefined && THREAD_TYPES.has(channel.type)) { const parentId = channel.parent_id; if (typeof parentId !== 'string') return []; thread = true; ids = [channelId]; channel = await this.channel(parentId); } if (channel === null) return this.listing(root, payload, 'channels'); const guildId = channel.guild_id; if (channel.type === undefined || !TEXT_CHANNEL_TYPES.has(channel.type) || typeof guildId !== 'string') { return []; } const guild = await this.guildDir(guildId); if (guild === null) return [eventAt(root, '', FileChangeKind.UNKNOWN)]; const channelDir = `${guild}/channels/${channelDirname(channel)}`; const changes = new Map(); for (const messageId of ids) { const day = dayOf(messageId); if (day === null) continue; const dayDir = `${channelDir}/${day}`; changes.set(`${dayDir}/${CHAT_FILE}`, FileChangeKind.UPDATE); if (thread || REACTION_EVENTS.has(eventType)) continue; if (eventType !== 'MESSAGE_CREATE') { changes.set(`${dayDir}/${FILES_DIR}`, FileChangeKind.UNKNOWN); continue; } const attachments = field(payload, 'attachments'); for (const att of Array.isArray(attachments) ? attachments : []) { if (att === null || typeof att !== 'object' || Array.isArray(att) || !isListed(att)) { continue; } const name = fileBlobName({ id: att.id, ...(typeof att.filename === 'string' ? { filename: att.filename } : {}), ...(typeof att.title === 'string' ? { title: att.title } : {}), }); changes.set(`${dayDir}/${FILES_DIR}/${name}`, FileChangeKind.CREATE); } } return [...changes].map(([path, kind]) => eventAt(root, path, kind)); } async listing(root, payload, name) { const guildId = textField(payload, 'guild_id'); if (guildId === null) return []; const guild = await this.guildDir(guildId); if (guild === null) return [eventAt(root, '', FileChangeKind.UNKNOWN)]; return [eventAt(root, `${guild}/${name}`, FileChangeKind.UNKNOWN)]; } async toEvents(root, eventType, payload) { if (MESSAGE_EVENTS.has(eventType) || REACTION_EVENTS.has(eventType)) { const own = textField(payload, MESSAGE_EVENTS.has(eventType) ? 'id' : 'message_id'); return this.messages(root, eventType, payload, own !== null ? [own] : []); } if (eventType === 'MESSAGE_DELETE_BULK') { const listed = field(payload, 'ids'); const ids = (Array.isArray(listed) ? listed : []).filter((i) => typeof i === 'string'); return this.messages(root, eventType, payload, ids); } const own = textField(payload, 'id'); if (CHANNEL_EVENTS.has(eventType)) { if (own !== null && payload !== null && typeof payload === 'object') { this.channels.delete(own); if (eventType !== 'CHANNEL_DELETE') { this.channels.set(own, { ...payload, id: own }); } } return this.listing(root, payload, 'channels'); } if (MEMBER_EVENTS.has(eventType)) return this.listing(root, payload, 'members'); if (GUILD_EVENTS.has(eventType)) { if (own !== null) { this.guilds.delete(own); for (const [channelId, channel] of [...this.channels]) { if (channel.guild_id === own) this.channels.delete(channelId); } const name = textField(payload, 'name'); if (name !== null) this.guilds.set(own, guildDirname({ id: own, name })); } return [eventAt(root, '', FileChangeKind.UNKNOWN)]; } return []; } } //# sourceMappingURL=events.js.map