feat: add channels implementation with brain manager improvement

This commit is contained in:
2026-06-28 23:54:27 +09:00
parent 3cac93d97c
commit f595780bc4
6 changed files with 198 additions and 51 deletions

View File

@@ -8,6 +8,8 @@
"@openrouter/sdk": "^0.12.79",
"chalk": "^5.6.2",
"commander": "^15.0.0",
"discord.js": "^14.26.4",
"gramio": "^0.12.0",
"prettier": "^3.8.3",
"supermemory": "^4.24.12",
"yaml": "^2.9.0",
@@ -22,24 +24,94 @@
},
},
"packages": {
"@discordjs/builders": ["@discordjs/builders@1.14.1", "", { "dependencies": { "@discordjs/formatters": "^0.6.2", "@discordjs/util": "^1.2.0", "@sapphire/shapeshift": "^4.0.0", "discord-api-types": "^0.38.40", "fast-deep-equal": "^3.1.3", "ts-mixer": "^6.0.4", "tslib": "^2.6.3" } }, "sha512-gSKkhXLqs96TCzk66VZuHHl8z2bQMJFGwrXC0f33ngK+FLNau4hU1PYny3DNJfNdSH+gVMzE85/d5FQ2BpcNwQ=="],
"@discordjs/collection": ["@discordjs/collection@1.5.3", "", {}, "sha512-SVb428OMd3WO1paV3rm6tSjM4wC+Kecaa1EUGX7vc6/fddvw/6lg90z4QtCqm21zvVe92vMMDt9+DkIvjXImQQ=="],
"@discordjs/formatters": ["@discordjs/formatters@0.6.2", "", { "dependencies": { "discord-api-types": "^0.38.33" } }, "sha512-y4UPwWhH6vChKRkGdMB4odasUbHOUwy7KL+OVwF86PvT6QVOwElx+TiI1/6kcmcEe+g5YRXJFiXSXUdabqZOvQ=="],
"@discordjs/rest": ["@discordjs/rest@2.6.1", "", { "dependencies": { "@discordjs/collection": "^2.1.1", "@discordjs/util": "^1.2.0", "@sapphire/async-queue": "^1.5.3", "@sapphire/snowflake": "^3.5.5", "@vladfrangu/async_event_emitter": "^2.4.6", "discord-api-types": "^0.38.40", "magic-bytes.js": "^1.13.0", "tslib": "^2.6.3", "undici": "6.24.1" } }, "sha512-wwQdgjeaoYFiaG+atbqx6aJDpqW7JHAo0HrQkBTbYzM3/PJ3GweQIpgElNcGZ26DCUOXMyawYd0YF7vtr+fZXg=="],
"@discordjs/util": ["@discordjs/util@1.2.0", "", { "dependencies": { "discord-api-types": "^0.38.33" } }, "sha512-3LKP7F2+atl9vJFhaBjn4nOaSWahZ/yWjOvA4e5pnXkt2qyXRCHLxoBQy81GFtLGCq7K9lPm9R517M1U+/90Qg=="],
"@discordjs/ws": ["@discordjs/ws@1.2.3", "", { "dependencies": { "@discordjs/collection": "^2.1.0", "@discordjs/rest": "^2.5.1", "@discordjs/util": "^1.1.0", "@sapphire/async-queue": "^1.5.2", "@types/ws": "^8.5.10", "@vladfrangu/async_event_emitter": "^2.2.4", "discord-api-types": "^0.38.1", "tslib": "^2.6.2", "ws": "^8.17.0" } }, "sha512-wPlQDxEmlDg5IxhJPuxXr3Vy9AjYq5xCvFWGJyD7w7Np8ZGu+Mc+97LCoEc/+AYCo2IDpKioiH0/c/mj5ZR9Uw=="],
"@gramio/callback-data": ["@gramio/callback-data@0.1.0", "", {}, "sha512-eqpR2Bod7dySDLDu8RB3xJdcrwyE3ZrhHaP+v0xOqWbgOCGY+gZp37E2E0KeXq/ko/8DLp3XeTaQD61++kiK6A=="],
"@gramio/composer": ["@gramio/composer@0.5.0", "", {}, "sha512-CP8DxNzDmVl2uO/L2VtjYycLi6jrPkZvTJoN/qVtct2Ms0oUKlKFgnNidH/i2R41HUow2fU0tlcGb1LR4NPHtg=="],
"@gramio/contexts": ["@gramio/contexts@0.9.0", "", { "peerDependencies": { "@gramio/types": "^10.1.0", "inspectable": "^3.0.1" } }, "sha512-94TQN69DyREAE9TD0ayOXHRxouOlWr7JP0Fv1oFbLwyCgKBTK7+I3JAn2RZiGWDeQ8n0oTifoEqBDPLDrMgSzg=="],
"@gramio/files": ["@gramio/files@0.6.1", "", { "dependencies": { "@gramio/types": "^10.0.0" } }, "sha512-by1v+a3D/9e5kO0Yl7Ob92iSXMdorqLvTscb+wwA4z6YBMGmnwL8I7CqzL0yFUUI0Xzi9S7uKbPBWm7VH4GZ8w=="],
"@gramio/format": ["@gramio/format@0.9.0", "", { "dependencies": { "@gramio/types": "^10.1.0" }, "peerDependencies": { "marked": "^15.0.11", "node-html-parser": ">=6.0.0" }, "optionalPeers": ["marked", "node-html-parser"] }, "sha512-dU0DWlUo01wL+yL8Pi/itFfKhxvqn8WXKQ+maYsjTFSgfo3iHF96kAsrmdwszJjYSBioWcWD2DhKJB4S1k9dXA=="],
"@gramio/keyboards": ["@gramio/keyboards@1.4.0", "", { "dependencies": { "@gramio/types": ">=9.6.0" } }, "sha512-sUjXV/LQmXIXRd2H0dwVXObeM+3OeUuxR/rt1hi2CUoyg5aaFy8wUfMjVMhx/9IXqHCQ7FK72zS9sqVXkvYCeg=="],
"@gramio/types": ["@gramio/types@10.1.0", "", {}, "sha512-vced/i5k2wbkkcOZvD3LJgv7EjPK6oJOL12u0Piag+JDDSXJt/fejPPXXG+st0vYoIQR5bd4ndrWi4Cn5PshFQ=="],
"@openrouter/sdk": ["@openrouter/sdk@0.12.79", "", { "dependencies": { "zod": "^3.25.0 || ^4.0.0" } }, "sha512-0ZpwtnuHh3/B1piW9kHCUIQy6PAsaK/vjFdZuHxmCdAenCyUNsLA2mFpmfHNWRNb+bOO3yBc4IALa264UyzmBA=="],
"@sapphire/async-queue": ["@sapphire/async-queue@1.5.5", "", {}, "sha512-cvGzxbba6sav2zZkH8GPf2oGk9yYoD5qrNWdu9fRehifgnFZJMV+nuy2nON2roRO4yQQ+v7MK/Pktl/HgfsUXg=="],
"@sapphire/shapeshift": ["@sapphire/shapeshift@4.0.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "lodash": "^4.17.21" } }, "sha512-d9dUmWVA7MMiKobL3VpLF8P2aeanRTu6ypG2OIaEv/ZHH/SUQ2iHOVyi5wAPjQ+HmnMuL0whK9ez8I/raWbtIg=="],
"@sapphire/snowflake": ["@sapphire/snowflake@3.5.3", "", {}, "sha512-jjmJywLAFoWeBi1W7994zZyiNWPIiqRRNAmSERxyg93xRGzNYvGjlZ0gR6x0F4gPRi2+0O6S71kOZYyr3cxaIQ=="],
"@types/node": ["@types/node@25.9.1", "", { "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } }, "sha512-xfrlY7UD5rMJk3ZVJP8BNzS28J36YJg+xp+LPXV1TdWxr8uMH5A860QNxYDGQe/ylDSgjxE52Q9VnO7p75tJxg=="],
"@types/ws": ["@types/ws@8.18.1", "", { "dependencies": { "@types/node": "*" } }, "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg=="],
"@vladfrangu/async_event_emitter": ["@vladfrangu/async_event_emitter@2.4.7", "", {}, "sha512-Xfe6rpCTxSxfbswi/W/Pz7zp1WWSNn4A0eW4mLkQUewCrXXtMj31lCg+iQyTkh/CkusZSq9eDflu7tjEDXUY6g=="],
"chalk": ["chalk@5.6.2", "", {}, "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA=="],
"commander": ["commander@15.0.0", "", {}, "sha512-z67u4ZhzCL/Tydu1lJARtEZYWbWaN7oYLHbsuzocr6y4N6WZAagG3RQ4FW61V1/0+jImpj293XfrcYnd1qxtPg=="],
"debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" }, "peerDependencies": { "supports-color": "*" }, "optionalPeers": ["supports-color"] }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
"discord-api-types": ["discord-api-types@0.38.49", "", {}, "sha512-XnqcWmnFZFAE8ZM8SHAw9DIV8D3Or00rMQ8iQLotrEA2PmXhl+ykaf6L6q4l474hrSUH1JaYcv+iOMRWp2p6Tg=="],
"discord.js": ["discord.js@14.26.4", "", { "dependencies": { "@discordjs/builders": "^1.14.1", "@discordjs/collection": "1.5.3", "@discordjs/formatters": "^0.6.2", "@discordjs/rest": "^2.6.1", "@discordjs/util": "^1.2.0", "@discordjs/ws": "^1.2.3", "@sapphire/snowflake": "3.5.3", "discord-api-types": "^0.38.40", "fast-deep-equal": "3.1.3", "lodash.snakecase": "4.1.1", "magic-bytes.js": "^1.13.0", "tslib": "^2.6.3", "undici": "6.24.1" } }, "sha512-4oBp8tc6Kf8IDBwAHhbsMaAqx1b5fob9SNasZT7V6yyyUydoO5i5fGuX7TmvRtR+q/WgKRnRViRoAWnG7fNyvA=="],
"fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="],
"gramio": ["gramio@0.12.0", "", { "dependencies": { "@gramio/callback-data": "^0.1.0", "@gramio/composer": "^0.5.0", "@gramio/contexts": "^0.9.0", "@gramio/files": "^0.6.1", "@gramio/format": "^0.9.0", "@gramio/keyboards": "^1.4.0", "@gramio/types": "^10.1.0", "debug": "^4.4.3" } }, "sha512-SrUGgNbur5jqe8xf5e4a1QYEX5bbIPnJV+rclcBReRwGZwUShIR0aAahe3dRRqxYIXbxFr1oShYL7q/6Nwi+BQ=="],
"inspectable": ["inspectable@3.0.2", "", {}, "sha512-XHygPjNXXe1TWQLlVdCYLejUklsGGo+XWS3Cn/RlkvnhkbZqcQxoXPrSh7Via5aATYJUMWVnrD5CbW2c+BtKMA=="],
"lodash": ["lodash@4.18.1", "", {}, "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q=="],
"lodash.snakecase": ["lodash.snakecase@4.1.1", "", {}, "sha512-QZ1d4xoBHYUeuouhEq3lk3Uq7ldgyFXGBhg04+oRLnIz8o9T65Eh+8YdroUwn846zchkA9yDsDl5CVVaV2nqYw=="],
"magic-bytes.js": ["magic-bytes.js@1.13.0", "", {}, "sha512-afO2mnxW7GDTXMm5/AoN1WuOcdoKhtgXjIvHmobqTD1grNplhGdv3PFOyjCVmrnOZBIT/gD/koDKpYG+0mvHcg=="],
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
"prettier": ["prettier@3.8.3", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-7igPTM53cGHMW8xWuVTydi2KO233VFiTNyF5hLJqpilHfmn8C8gPf+PS7dUT64YcXFbiMGZxS9pCSxL/Dxm/Jw=="],
"supermemory": ["supermemory@4.24.12", "", { "bin": { "supermemory": "bin/cli" } }, "sha512-xAFextuqk4JuoW33jJaFGqT1oMppN2IgfWUrV18Fv3qAAZ6M1SR1tb+7EBq8vrEQIx4iY2MQh5p+qnfL6lI8Yw=="],
"ts-mixer": ["ts-mixer@6.0.4", "", {}, "sha512-ufKpbmrugz5Aou4wcr5Wc1UUFWOLhq+Fm6qa6P0w0K5Qw2yhaUoiWszhCVuNQyNwrlGiscHOmqYoAox1PtvgjA=="],
"tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
"undici": ["undici@6.24.1", "", {}, "sha512-sC+b0tB1whOCzbtlx20fx3WgCXwkW627p4EA9uM+/tNNPkSS+eSEld6pAs9nDv7WbY1UUljBMYPtu9BCOrCWKA=="],
"undici-types": ["undici-types@7.24.6", "", {}, "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg=="],
"ws": ["ws@8.21.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g=="],
"yaml": ["yaml@2.9.0", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA=="],
"zod": ["zod@4.4.3", "", {}, "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ=="],
"@discordjs/rest/@discordjs/collection": ["@discordjs/collection@2.1.1", "", {}, "sha512-LiSusze9Tc7qF03sLCujF5iZp7K+vRNEDBZ86FT9aQAv3vxMLihUvKvpsCWiQ2DJq1tVckopKm1rxomgNUc9hg=="],
"@discordjs/rest/@sapphire/snowflake": ["@sapphire/snowflake@3.5.5", "", {}, "sha512-xzvBr1Q1c4lCe7i6sRnrofxeO1QTP/LKQ6A6qy0iB4x5yfiSfARMEQEghojzTNALDTcv8En04qYNIco9/K9eZQ=="],
"@discordjs/ws/@discordjs/collection": ["@discordjs/collection@2.1.1", "", {}, "sha512-LiSusze9Tc7qF03sLCujF5iZp7K+vRNEDBZ86FT9aQAv3vxMLihUvKvpsCWiQ2DJq1tVckopKm1rxomgNUc9hg=="],
}
}

View File

@@ -20,6 +20,8 @@
"@openrouter/sdk": "^0.12.79",
"chalk": "^5.6.2",
"commander": "^15.0.0",
"discord.js": "^14.26.4",
"gramio": "^0.12.0",
"prettier": "^3.8.3",
"supermemory": "^4.24.12",
"yaml": "^2.9.0",

View File

@@ -1,95 +1,99 @@
import { config } from "@/config";
import { mkdir, readFile, rm, writeFile } from "fs/promises";
import { mkdir, readFile, writeFile } from "fs/promises";
import { join } from "path";
export type ChannelKeys = "discord" | "telegram";
export interface BrainDiscordConfig {
token: string;
}
export interface BrainTelegramConfig {
token: string;
}
export interface BrainItem {
brainId: string;
spaceName: string;
displayName: string;
baseSystemPrompt: string;
activated: boolean;
channel?: ChannelKeys;
discord?: BrainDiscordConfig;
telegram?: BrainTelegramConfig;
}
export type BrainItemDiscord = Omit<BrainItem, "channel" | ChannelKeys> & {
channel: "discord";
discord: BrainDiscordConfig;
};
export type BrainItemTelegram = Omit<BrainItem, "channel" | ChannelKeys> & {
channel: "telegram";
discord: BrainTelegramConfig;
};
export type BrainItemWithChannel = BrainItemDiscord | BrainItemTelegram;
export type BrainList = BrainItem[];
// Layout:
// <root>/brains.json — BrainItem[] index, mirror
// <root>/<brainId>/brain.json — BrainItem per brain, source of truth
export class BrainDBManager {
constructor(private readonly root: string = config.brainboxRoot) {}
private brainDir(brainId: string): string {
return join(this.root, brainId);
}
private brainFile(brainId: string): string {
return join(this.brainDir(brainId), "brain.json");
}
private indexFile(): string {
private dbFile(): string {
return join(this.root, "brains.json");
}
private async readIndex(): Promise<BrainList> {
private async readDB(): Promise<BrainList> {
try {
const content = await readFile(this.indexFile(), { encoding: "utf-8" });
const content = await readFile(this.dbFile(), { encoding: "utf-8" });
return JSON.parse(content) as BrainList;
} catch {
return [];
}
}
private async writeIndex(list: BrainList): Promise<void> {
private async writeDB(list: BrainList): Promise<void> {
await mkdir(this.root, { recursive: true });
await writeFile(this.indexFile(), JSON.stringify(list, null, 2), {
await writeFile(this.dbFile(), JSON.stringify(list, null, 2), {
encoding: "utf-8",
});
}
private async writeBrain(brain: BrainItem): Promise<void> {
await mkdir(this.brainDir(brain.brainId), { recursive: true });
await writeFile(
this.brainFile(brain.brainId),
JSON.stringify(brain, null, 2),
{ encoding: "utf-8" },
);
}
async loadBrain(brainId: string): Promise<BrainItem | undefined> {
try {
const content = await readFile(this.brainFile(brainId), {
encoding: "utf-8",
});
return JSON.parse(content) as BrainItem;
} catch {
return undefined;
}
const list = await this.readDB();
return list.find((b) => b.brainId === brainId);
}
async saveBrain(brainId: string, brain: BrainItem): Promise<void> {
await this.writeBrain(brain);
const list = await this.readIndex();
const list = await this.readDB();
const idx = list.findIndex((b) => b.brainId === brainId);
if (idx >= 0) list[idx] = brain;
else list.push(brain);
await this.writeIndex(list);
await this.writeDB(list);
}
async listBrain(): Promise<Array<{ brainId: string; displayName: string }>> {
const list = await this.readIndex();
return list.map(({ brainId, displayName }) => ({ brainId, displayName }));
async listAvailableBrain(): Promise<BrainItemWithChannel[]> {
return (await this.readDB()).filter((b) =>
this.isBrainReady(b),
) as BrainItemWithChannel[];
}
async deleteBrain(brainId: string): Promise<void> {
await rm(this.brainDir(brainId), { recursive: true, force: true });
const list = await this.readIndex();
const list = await this.readDB();
const filtered = list.filter((b) => b.brainId !== brainId);
if (filtered.length === list.length) return;
await this.writeIndex(filtered);
await this.writeDB(filtered);
}
async isBrainAvailable(brainId: string): Promise<boolean> {
return (await this.loadBrain(brainId)) !== undefined;
const item = await this.loadBrain(brainId);
return item !== undefined && this.isBrainReady(item);
}
private isBrainReady(item: BrainItem): boolean {
if (!item.activated) return false;
switch (item.channel) {
case "discord":
return !!item.discord?.token;
case "telegram":
return !!item.telegram?.token;
default:
return false;
}
}
}

View File

@@ -1,11 +1,12 @@
import type { Brain } from "@/brain";
import type { AvailabilityStatus } from "@/openrouter/schema";
export interface BaseChannel {
readonly brain: Brain;
export abstract class BaseChannel {
constructor(public readonly brain: Brain) {}
init(): Promise<void>;
abstract init(): Promise<void>;
send(text: string, opts?: { replyTo?: string }): Promise<void>;
setAvailability(status: AvailabilityStatus): Promise<void>;
abstract send(text: string, opts?: { replyTo?: string }): Promise<void>;
abstract setAvailability(status: AvailabilityStatus): Promise<void>;
}

34
src/channel/discord.ts Normal file
View File

@@ -0,0 +1,34 @@
import { Client, Events, GatewayIntentBits } from "discord.js";
import { z } from "zod";
import type { AvailabilityStatus } from "@/openrouter/schema";
import { logger } from "@/utils/logger";
import { BaseChannel } from "./base";
const discordConfigSchema = z.object({
token: z.string().min(1),
});
export class DiscordChannel extends BaseChannel {
private client?: Client;
async init(): Promise<void> {
const { token } = discordConfigSchema.parse({
token: this.brain.brainbase.discord?.token,
});
this.client = new Client({ intents: [GatewayIntentBits.Guilds] });
this.client.once(Events.ClientReady, (c) => {
logger.success(`Discord ready as ${c.user.tag}`);
});
await this.client.login(token);
}
async send(_text: string, _opts?: { replyTo?: string }): Promise<void> {
// ponytail: stub — wire up this.client.channels to send
throw new Error("DiscordChannel.send not implemented");
}
async setAvailability(_status: AvailabilityStatus): Promise<void> {
// ponytail: stub — wire up this.client.user.setPresence / setStatus
throw new Error("DiscordChannel.setAvailability not implemented");
}
}

34
src/channel/telegram.ts Normal file
View File

@@ -0,0 +1,34 @@
import { Bot } from "gramio";
import { z } from "zod";
import type { AvailabilityStatus } from "@/openrouter/schema";
import { logger } from "@/utils/logger";
import { BaseChannel } from "./base";
const telegramConfigSchema = z.object({
token: z.string().min(1),
});
export class TelegramChannel extends BaseChannel {
private bot?: Bot;
async init(): Promise<void> {
const { token } = telegramConfigSchema.parse({
token: this.brain.brainbase.telegram?.token,
});
this.bot = new Bot(token);
this.bot.onStart(({ info }) => {
logger.success(`Telegram ready as @${info.username}`);
});
await this.bot.start();
}
async send(_text: string, _opts?: { replyTo?: string }): Promise<void> {
// ponytail: stub — wire up this.bot.api.sendMessage
throw new Error("TelegramChannel.send not implemented");
}
async setAvailability(_status: AvailabilityStatus): Promise<void> {
// ponytail: stub — Telegram has no presence concept; map to custom status or no-op
throw new Error("TelegramChannel.setAvailability not implemented");
}
}