Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions platform/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -221,18 +221,23 @@ export type {
WorkspaceSourceRepoSyncResult,
} from "./workspace-source-repo";
export {
addDiscordSource,
addObsidianSource,
DEFAULT_DISCORD_MAX_MESSAGES,
DEFAULT_OBSIDIAN_EXCLUDE_GLOBS,
getAgentsDir,
getSourcesConfigPath,
loadSourcesConfig,
markSourceIndexed,
parseDiscordSettings,
removeSource,
saveSourcesConfig,
} from "./sources-config";
export type {
AddDiscordSourceInput,
AddObsidianSourceInput,
AddSourceResult,
DiscordSourceSettings,
RemoveSourceResult,
SignetSourceEntry,
SignetSourceKind,
Expand Down
120 changes: 120 additions & 0 deletions platform/core/src/sources-config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,12 @@ import { tmpdir } from "node:os";
import { join } from "node:path";
import {
DEFAULT_OBSIDIAN_EXCLUDE_GLOBS,
addDiscordSource,
addObsidianSource,
getSourcesConfigPath,
loadSourcesConfig,
markSourceIndexed,
parseDiscordSettings,
removeSource,
} from "./sources-config";

Expand Down Expand Up @@ -152,4 +154,122 @@ describe("sources-config", () => {
if (removed.ok === true) throw new Error("expected removeSource to fail");
expect(removed.error).toContain("not found");
});

describe("discord source", () => {
it("adds a Discord source with guild IDs and token ref", () => {
const agentsDir = tmp();
const result = addDiscordSource(
{
guildIds: ["123456789012345678"],
tokenRef: "DISCORD_BOT_TOKEN",
name: "My Discord Server",
now: "2026-01-01T00:00:00.000Z",
},
agentsDir,
);

expect(result.ok).toBe(true);
if (result.ok === false) throw new Error(result.error);
expect(result.created).toBe(true);
expect(result.source.kind).toBe("discord");
expect(result.source.mode).toBe("read-only");
expect(result.source.name).toBe("My Discord Server");
expect(result.source.root).toBe("");
expect(result.source.settings).toBeDefined();
expect((result.source.settings as { guildIds: string[] }).guildIds).toEqual(["123456789012345678"]);
expect((result.source.settings as { tokenRef: string }).tokenRef).toBe("DISCORD_BOT_TOKEN");
});

it("rejects missing guild IDs", () => {
const agentsDir = tmp();
const result = addDiscordSource(
{ guildIds: [], tokenRef: "TOKEN" },
agentsDir,
);
expect(result.ok).toBe(false);
if (result.ok === true) throw new Error("expected failure");
expect(result.error).toContain("guild ID");
});

it("rejects missing token ref", () => {
const agentsDir = tmp();
const result = addDiscordSource(
{ guildIds: ["123456789012345678"], tokenRef: "" },
agentsDir,
);
expect(result.ok).toBe(false);
if (result.ok === true) throw new Error("expected failure");
expect(result.error).toContain("token");
});

it("rejects invalid guild ID format", () => {
const agentsDir = tmp();
const result = addDiscordSource(
{ guildIds: ["not-a-number"], tokenRef: "TOKEN" },
agentsDir,
);
expect(result.ok).toBe(false);
if (result.ok === true) throw new Error("expected failure");
expect(result.error).toContain("Invalid Discord guild ID");
});

it("deduplicates by sorted guild IDs", () => {
const agentsDir = tmp();
const first = addDiscordSource(
{ guildIds: ["111111111111111111"], tokenRef: "TOKEN", name: "Server A", now: "2026-01-01T00:00:00.000Z" },
agentsDir,
);
const second = addDiscordSource(
{ guildIds: ["111111111111111111"], tokenRef: "TOKEN2", name: "Server B", now: "2026-01-02T00:00:00.000Z" },
agentsDir,
);

expect(first.ok).toBe(true);
expect(second.ok).toBe(true);
if (second.ok === false) throw new Error(second.error);
expect(second.created).toBe(false);
expect(second.source.name).toBe("Server B");
expect(loadSourcesConfig(agentsDir).sources).toHaveLength(1);
});

it("parseDiscordSettings returns defaults for empty input", () => {
const settings = parseDiscordSettings(undefined);
expect(settings.guildIds).toEqual([]);
expect(settings.tokenRef).toBe("");
expect(settings.includeThreads).toBe(true);
});

it("parseDiscordSettings preserves valid fields", () => {
const settings = parseDiscordSettings({
guildIds: ["123"],
tokenRef: "MY_TOKEN",
channelFilter: ["general"],
maxMessagesPerChannel: 500,
includeThreads: false,
since: "2026-01-01",
});
expect(settings.guildIds).toEqual(["123"]);
expect(settings.tokenRef).toBe("MY_TOKEN");
expect(settings.channelFilter).toEqual(["general"]);
expect(settings.maxMessagesPerChannel).toBe(500);
expect(settings.includeThreads).toBe(false);
expect(settings.since).toBe("2026-01-01");
});

it("removes a Discord source by id", () => {
const agentsDir = tmp();
const added = addDiscordSource(
{ guildIds: ["123456789012345678"], tokenRef: "TOKEN", now: "2026-01-01T00:00:00.000Z" },
agentsDir,
);
expect(added.ok).toBe(true);
if (added.ok === false) throw new Error(added.error);

const removed = removeSource(added.source.id, agentsDir);
expect(removed.ok).toBe(true);
if (removed.ok === false) throw new Error(removed.error);
expect(removed.source.id).toBe(added.source.id);
expect(loadSourcesConfig(agentsDir).sources).toEqual([]);
});
});
});
122 changes: 119 additions & 3 deletions platform/core/src/sources-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, statSync, writ
import { homedir } from "node:os";
import { dirname, resolve } from "node:path";

export type SignetSourceKind = "obsidian";
export type SignetSourceKind = "obsidian" | "discord";
export type SignetSourceMode = "read-only";

export interface SignetSourceEntry {
Expand All @@ -17,6 +17,7 @@ export interface SignetSourceEntry {
readonly updatedAt: string;
readonly lastIndexedAt?: string;
readonly excludeGlobs?: readonly string[];
readonly settings?: Readonly<Record<string, unknown>>;
}

export const DEFAULT_OBSIDIAN_EXCLUDE_GLOBS = [
Expand All @@ -39,6 +40,28 @@ export interface AddObsidianSourceInput {
readonly now?: string;
}

export interface DiscordSourceSettings {
readonly guildIds: readonly string[];
readonly tokenRef: string;
readonly channelFilter?: readonly string[];
readonly maxMessagesPerChannel?: number;
readonly includeThreads?: boolean;
readonly since?: string;
}

export const DEFAULT_DISCORD_MAX_MESSAGES = 1000;

export interface AddDiscordSourceInput {
readonly guildIds: readonly string[];
readonly tokenRef: string;
readonly name?: string;
readonly channelFilter?: readonly string[];
readonly maxMessagesPerChannel?: number;
readonly includeThreads?: boolean;
readonly since?: string;
readonly now?: string;
}

export type AddSourceResult =
| { readonly ok: true; readonly source: SignetSourceEntry; readonly created: boolean }
| { readonly ok: false; readonly error: string };
Expand Down Expand Up @@ -105,6 +128,98 @@ export function addObsidianSource(input: AddObsidianSourceInput, agentsDir = get
return withSourcesConfigLock(agentsDir, () => addObsidianSourceUnlocked(input, agentsDir));
}

export function addDiscordSource(input: AddDiscordSourceInput, agentsDir = getAgentsDir()): AddSourceResult {
return withSourcesConfigLock(agentsDir, () => addDiscordSourceUnlocked(input, agentsDir));
}

function addDiscordSourceUnlocked(input: AddDiscordSourceInput, agentsDir = getAgentsDir()): AddSourceResult {
try {
return addDiscordSourceChecked(input, agentsDir);
} catch (err) {
const detail = err instanceof Error ? err.message : String(err);
return { ok: false, error: detail };
}
}

function addDiscordSourceChecked(input: AddDiscordSourceInput, agentsDir = getAgentsDir()): AddSourceResult {
if (!input.guildIds || input.guildIds.length === 0) {
return { ok: false, error: "At least one Discord guild ID is required" };
}
const trimmedTokenRef = input.tokenRef?.trim();
if (!trimmedTokenRef) return { ok: false, error: "Discord bot token reference (tokenRef) is required" };
for (const id of input.guildIds) {
if (!/^\d{17,20}$/.test(id.trim())) {
return { ok: false, error: `Invalid Discord guild ID: ${id}` };
}
}

const guildIds = input.guildIds.map((id) => id.trim());
const key = guildIds.slice().sort().join(",");
const now = input.now ?? new Date().toISOString();
const cfg = loadSourcesConfigForWrite(agentsDir);
const sourceId = `discord:${createHash("sha256").update(key).digest("hex").slice(0, 16)}`;
const existing = cfg.sources.find((source) => source.id === sourceId);

if (existing) {
const updated = {
...existing,
name: cleanName(input.name) ?? existing.name,
enabled: true,
settings: buildDiscordSettings(input),
updatedAt: now,
};
saveSourcesConfig(
{
version: SOURCES_CONFIG_VERSION,
sources: cfg.sources.map((source) => (source.id === existing.id ? updated : source)),
},
agentsDir,
);
return { ok: true, source: updated, created: false };
}

const source: SignetSourceEntry = {
id: sourceId,
kind: "discord",
name: cleanName(input.name) ?? "Discord Server",
root: "",
enabled: true,
mode: "read-only",
createdAt: now,
updatedAt: now,
settings: buildDiscordSettings(input),
};
saveSourcesConfig({ version: SOURCES_CONFIG_VERSION, sources: [...cfg.sources, source] }, agentsDir);
return { ok: true, source, created: true };
}

function buildDiscordSettings(input: AddDiscordSourceInput): Record<string, unknown> {
return {
guildIds: input.guildIds.map((id) => id.trim()),
tokenRef: input.tokenRef.trim(),
...(input.channelFilter && input.channelFilter.length > 0 ? { channelFilter: input.channelFilter } : {}),
maxMessagesPerChannel: input.maxMessagesPerChannel ?? DEFAULT_DISCORD_MAX_MESSAGES,
includeThreads: input.includeThreads ?? true,
...(input.since ? { since: input.since } : {}),
};
}

export function parseDiscordSettings(raw?: Readonly<Record<string, unknown>>): DiscordSourceSettings {
if (!raw) {
return { guildIds: [], tokenRef: "", includeThreads: true };
}
const guildIds = Array.isArray(raw.guildIds) ? raw.guildIds.filter((id): id is string => typeof id === "string") : [];
const tokenRef = typeof raw.tokenRef === "string" ? raw.tokenRef : "";
const channelFilter = Array.isArray(raw.channelFilter)
? raw.channelFilter.filter((id): id is string => typeof id === "string")
: undefined;
const maxMessagesPerChannel =
typeof raw.maxMessagesPerChannel === "number" ? raw.maxMessagesPerChannel : DEFAULT_DISCORD_MAX_MESSAGES;
const includeThreads = typeof raw.includeThreads === "boolean" ? raw.includeThreads : true;
const since = typeof raw.since === "string" ? raw.since : undefined;
return { guildIds, tokenRef, channelFilter, maxMessagesPerChannel, includeThreads, since };
}

function addObsidianSourceUnlocked(input: AddObsidianSourceInput, agentsDir = getAgentsDir()): AddSourceResult {
try {
return addObsidianSourceChecked(input, agentsDir);
Expand Down Expand Up @@ -272,7 +387,7 @@ function isRecord(value: unknown): value is Record<string, unknown> {
function isSourceEntry(value: unknown): value is SignetSourceEntry {
return (
isRecord(value) &&
value.kind === "obsidian" &&
(value.kind === "obsidian" || value.kind === "discord") &&
typeof value.id === "string" &&
typeof value.name === "string" &&
typeof value.root === "string" &&
Expand All @@ -282,6 +397,7 @@ function isSourceEntry(value: unknown): value is SignetSourceEntry {
typeof value.updatedAt === "string" &&
(value.lastIndexedAt === undefined || typeof value.lastIndexedAt === "string") &&
(value.excludeGlobs === undefined ||
(Array.isArray(value.excludeGlobs) && value.excludeGlobs.every((entry) => typeof entry === "string")))
(Array.isArray(value.excludeGlobs) && value.excludeGlobs.every((entry) => typeof entry === "string"))) &&
(value.settings === undefined || isRecord(value.settings))
);
}
25 changes: 25 additions & 0 deletions platform/daemon/src/daemon.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ import { migrateConfig } from "./config-migration";
import { listConnectors } from "./connectors/registry";
import { clearAllPresence } from "./cross-agent";
import { closeDbAccessor, getDbAccessor, getVectorRuntimeStatus, initDbAccessor } from "./db-accessor";
import { syncDiscordSource } from "./discord-source-bridge";
import { fetchEmbedding } from "./embedding-fetch";
import { type EmbeddingTrackerHandle, startEmbeddingTracker } from "./embedding-tracker";
import { initFeatureFlags } from "./feature-flags";
Expand Down Expand Up @@ -1610,6 +1611,30 @@ async function main() {
});
}

for (const source of loadSourcesConfig(AGENTS_DIR).sources) {
if (!source.enabled || source.kind !== "discord") continue;
const startupEmbedCfg = loadMemoryConfig(AGENTS_DIR).embedding;
syncDiscordSource(source, {
agentId: resolveDaemonAgentId(),
embeddingConfig: startupEmbedCfg,
fetchEmbedding,
agentsDir: AGENTS_DIR,
})
.then((indexed) => {
logger.info("discord-source", "Discord source startup sync complete", {
sourceId: source.id,
indexed,
});
markSourceIndexed(source.id, undefined, AGENTS_DIR);
})
.catch((e) => {
logger.error("discord-source", "Discord source startup sync failed", undefined, {
sourceId: source.id,
error: e instanceof Error ? e.message : String(e),
});
});
}

const startupCfg = loadMemoryConfig(AGENTS_DIR);
if (startupCfg.embedding.provider !== "none") {
checkEmbeddingProvider(startupCfg.embedding)
Expand Down
Loading
Loading