@struktoai/mirage-core
Version:
A Unified Virtual Filesystem For AI Agents
167 lines • 8.01 kB
JavaScript
// ========= 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, textField } from "../../../events/index.js";
import { channelDirname, dmDirname } from "../formatters.js";
import { CHANNEL_LIST_EVENTS, CHAT_FILE, DM_LIST_EVENTS, FILES_DIR, ITEM_EVENTS, USER_LIST_EVENTS, } from "./constants.js";
import { affectedTs, channelIdOf, dayOf, itemChannel } from "./payload.js";
/**
* Map one Slack Events API delivery onto mount paths.
*
* The consumer runs the transport (an HTTP endpoint for the Events API or a
* Socket Mode websocket), unwraps the `event_callback` envelope and passes the
* inner event's `type` with its body.
*
* Every path this returns is rebuilt with the same functions `readdir` names
* directories with, which is the reason this lives beside the backend rather
* than in the consumer: a channel is `<name>__<C-id>` through `makeIdName`, a
* DM is the *user's* name through `dmDirname`, and the day is bucketed in UTC.
* Slack shows local time, so a consumer reimplementing the last rule would name
* tomorrow's directory for a fifth of the day and never see an error, because a
* notify on a path the mount does not serve evicts nothing.
*
* Resolution is the reason this holds state. An event names a channel by id
* only, and the directory carries the name, so the id has to be resolved
* through `conversations.info` (plus `users.info` for a DM, whose directory is
* named after the other person). That is one or two API calls the first time a
* conversation is seen and none after, against a Slack tier that allows roughly
* 50 a minute; a stateless mapper would spend a call per message. A rename
* drops the entry rather than patching it, since the whole subtree moved and
* the answer is a re-inventory anyway.
*
* Three kinds of event map to something coarser than a file, and honestly so. A
* listing change (a channel created, renamed or archived; a user's profile
* edited) is UNKNOWN on the container directory, because the entry names
* themselves changed. A shared file is UNKNOWN on that day's `files` directory,
* because the rendered filename comes from `fileBlobName` over metadata the
* notification does not carry, and the accompanying `message` event already
* refreshes `chat.jsonl`.
*
* Two file events are unmapped, and neither is unmappable. A file blob is
* addressed by the day it was *shared*, and neither event carries a
* conversation or that day: `file_change` sends only `file_id`, `file_deleted`
* sends `file_id` and the deletion's own `event_ts`. Asking Slack does not
* recover it either, since `files.info` on a deleted file answers with the
* `file_deleted` error. What does recover it is this mount's own index, which
* stores each blob's Slack id as `IndexEntry.id`, so a reverse lookup names the
* exact path; and a file the index has never seen is one nothing has cached, so
* there is nothing to invalidate. The hook simply is not handed the index
* today. Until it is, both ride the index TTL, which bounds the staleness
* rather than removing it.
*
* `channel_shared` / `channel_unshared` are a third that looks like a gap and
* is not: they change only the Slack Connect flags on the channel object, never
* its `name` or `id`, which are the two things the directory is spelled from.
*
* Mirrors Python `SlackEventHook` (`core/slack/events/hook.py`).
*/
export class SlackEventHook {
accessor;
dirs = new Map();
users = new Map();
constructor(accessor) {
this.accessor = accessor;
}
/** Display name for a user id, memoized. */
async userName(userId) {
const cached = this.users.get(userId);
if (cached !== undefined)
return cached;
const data = await this.accessor.transport.call('users.info', { user: userId });
const user = (data.user ?? {});
const name = user.name ?? userId;
this.users.set(userId, name);
return name;
}
/** Directory a conversation id maps to, memoized. */
async resolve(channelId) {
const cached = this.dirs.get(channelId);
if (cached !== undefined)
return cached;
const data = await this.accessor.transport.call('conversations.info', {
channel: channelId,
});
const channel = (data.channel ?? {});
const withId = { ...channel, id: channel.id ?? channelId };
let resolved;
if (withId.is_im === true || withId.is_mpim === true) {
const userId = withId.user ?? '';
const userMap = userId === '' ? {} : { [userId]: await this.userName(userId) };
resolved = { container: 'dms', dirname: dmDirname(withId, userMap) };
}
else {
resolved = { container: 'channels', dirname: channelDirname(withId) };
}
this.dirs.set(channelId, resolved);
return resolved;
}
/** Mount-relative day directory for a conversation and ts. */
async dayDir(channelId, ts) {
const day = dayOf(ts);
if (day === null)
return null;
const where = await this.resolve(channelId);
return `${where.container}/${where.dirname}/${day}`;
}
/** One UPDATE per day directory the stamps land in. */
async transcripts(root, channelId, stamps) {
const out = [];
for (const ts of stamps)
out.push(...(await this.transcript(root, channelId, ts)));
return out;
}
/** One UPDATE on the transcript a conversation and ts name. */
async transcript(root, channelId, ts) {
if (channelId === null || ts === null)
return [];
const dayDir = await this.dayDir(channelId, ts);
if (dayDir === null)
return [];
return [eventAt(root, `${dayDir}/${CHAT_FILE}`, FileChangeKind.UPDATE)];
}
/** Map a shared file onto that day's attachment directory. */
async fileShared(root, payload) {
const channelId = textField(payload, 'channel_id');
const ts = textField(payload, 'event_ts');
if (channelId === null || ts === null)
return [];
const dayDir = await this.dayDir(channelId, ts);
if (dayDir === null)
return [];
return [eventAt(root, `${dayDir}/${FILES_DIR}`, FileChangeKind.UNKNOWN)];
}
async toEvents(root, eventType, payload) {
if (eventType === 'message') {
return this.transcripts(root, textField(payload, 'channel'), affectedTs(payload));
}
if (ITEM_EVENTS.has(eventType)) {
const [channelId, ts] = itemChannel(payload);
return this.transcript(root, channelId, ts);
}
if (eventType === 'file_shared')
return this.fileShared(root, payload);
if (CHANNEL_LIST_EVENTS.has(eventType)) {
const channelId = channelIdOf(payload);
if (channelId !== null)
this.dirs.delete(channelId);
return [eventAt(root, 'channels', FileChangeKind.UNKNOWN)];
}
if (DM_LIST_EVENTS.has(eventType))
return [eventAt(root, 'dms', FileChangeKind.UNKNOWN)];
if (USER_LIST_EVENTS.has(eventType))
return [eventAt(root, 'users', FileChangeKind.UNKNOWN)];
return [];
}
}
//# sourceMappingURL=hook.js.map