feat: plugins v1.5

This commit is contained in:
2026-04-29 01:14:30 +02:00
parent 6920f93b41
commit eabbc08896
59 changed files with 2197 additions and 352 deletions

View File

@@ -28,5 +28,6 @@ Electron main-process package for MetoYou / Toju. This directory owns desktop bo
## Notes
- When adding a renderer-facing capability, update the Electron implementation, `preload.ts`, and the renderer bridge in `toju-app/` together.
- Plugin client data is stored in the local Electron SQLite database in the dedicated `plugin_data` table. Renderer plugins reach it through CQRS commands/queries exposed by the preload bridge; the signal server must not be used for arbitrary plugin data persistence.
- Treat `dist/electron/` and `dist-electron/` as generated output.
- See [AGENTS.md](AGENTS.md) for package-level editing rules.

View File

@@ -11,7 +11,8 @@ import {
ReactionEntity,
BanEntity,
AttachmentEntity,
MetaEntity
MetaEntity,
PluginDataEntity
} from '../../../entities';
export async function handleClearAllData(dataSource: DataSource): Promise<void> {
@@ -27,4 +28,5 @@ export async function handleClearAllData(dataSource: DataSource): Promise<void>
await dataSource.getRepository(BanEntity).clear();
await dataSource.getRepository(AttachmentEntity).clear();
await dataSource.getRepository(MetaEntity).clear();
await dataSource.getRepository(PluginDataEntity).clear();
}

View File

@@ -0,0 +1,14 @@
import { DataSource } from 'typeorm';
import { PluginDataEntity } from '../../../entities';
import { DeletePluginDataCommand } from '../../types';
export async function handleDeletePluginData(command: DeletePluginDataCommand, dataSource: DataSource): Promise<void> {
const { payload } = command;
await dataSource.getRepository(PluginDataEntity).delete({
key: payload.key,
pluginId: payload.pluginId,
scope: payload.scope,
serverId: payload.serverId ?? ''
});
}

View File

@@ -0,0 +1,10 @@
import { DataSource } from 'typeorm';
import { MetaEntity } from '../../../entities';
import { SaveMetaCommand } from '../../types';
export async function handleSaveMeta(command: SaveMetaCommand, dataSource: DataSource): Promise<void> {
await dataSource.getRepository(MetaEntity).save({
key: command.payload.key,
value: command.payload.value
});
}

View File

@@ -0,0 +1,16 @@
import { DataSource } from 'typeorm';
import { PluginDataEntity } from '../../../entities';
import { SavePluginDataCommand } from '../../types';
export async function handleSavePluginData(command: SavePluginDataCommand, dataSource: DataSource): Promise<void> {
const { payload } = command;
await dataSource.getRepository(PluginDataEntity).save({
key: payload.key,
pluginId: payload.pluginId,
scope: payload.scope,
serverId: payload.serverId ?? '',
updatedAt: Date.now(),
valueJson: JSON.stringify(payload.value ?? null)
});
}

View File

@@ -18,7 +18,10 @@ import {
SaveBanCommand,
RemoveBanCommand,
SaveAttachmentCommand,
DeleteAttachmentsForMessageCommand
DeleteAttachmentsForMessageCommand,
SavePluginDataCommand,
DeletePluginDataCommand,
SaveMetaCommand
} from '../types';
import { handleSaveMessage } from './handlers/saveMessage';
import { handleDeleteMessage } from './handlers/deleteMessage';
@@ -36,6 +39,9 @@ import { handleSaveBan } from './handlers/saveBan';
import { handleRemoveBan } from './handlers/removeBan';
import { handleSaveAttachment } from './handlers/saveAttachment';
import { handleDeleteAttachmentsForMessage } from './handlers/deleteAttachmentsForMessage';
import { handleSavePluginData } from './handlers/savePluginData';
import { handleDeletePluginData } from './handlers/deletePluginData';
import { handleSaveMeta } from './handlers/saveMeta';
import { handleClearAllData } from './handlers/clearAllData';
export const buildCommandHandlers = (dataSource: DataSource): Record<CommandTypeKey, (command: Command) => Promise<unknown>> => ({
@@ -55,5 +61,8 @@ export const buildCommandHandlers = (dataSource: DataSource): Record<CommandType
[CommandType.RemoveBan]: (cmd) => handleRemoveBan(cmd as RemoveBanCommand, dataSource),
[CommandType.SaveAttachment]: (cmd) => handleSaveAttachment(cmd as SaveAttachmentCommand, dataSource),
[CommandType.DeleteAttachmentsForMessage]: (cmd) => handleDeleteAttachmentsForMessage(cmd as DeleteAttachmentsForMessageCommand, dataSource),
[CommandType.SavePluginData]: (cmd) => handleSavePluginData(cmd as SavePluginDataCommand, dataSource),
[CommandType.DeletePluginData]: (cmd) => handleDeletePluginData(cmd as DeletePluginDataCommand, dataSource),
[CommandType.SaveMeta]: (cmd) => handleSaveMeta(cmd as SaveMetaCommand, dataSource),
[CommandType.ClearAllData]: () => handleClearAllData(dataSource)
});

View File

@@ -0,0 +1,11 @@
import { DataSource } from 'typeorm';
import { MetaEntity } from '../../../entities';
import { GetMetaQuery } from '../../types';
export async function handleGetMeta(query: GetMetaQuery, dataSource: DataSource): Promise<string | null> {
const meta = await dataSource.getRepository(MetaEntity).findOne({
where: { key: query.payload.key }
});
return meta?.value ?? null;
}

View File

@@ -0,0 +1,25 @@
import { DataSource } from 'typeorm';
import { PluginDataEntity } from '../../../entities';
import { GetPluginDataQuery } from '../../types';
export async function handleGetPluginData(query: GetPluginDataQuery, dataSource: DataSource): Promise<unknown> {
const { payload } = query;
const record = await dataSource.getRepository(PluginDataEntity).findOne({
where: {
key: payload.key,
pluginId: payload.pluginId,
scope: payload.scope,
serverId: payload.serverId ?? ''
}
});
if (!record) {
return null;
}
try {
return JSON.parse(record.valueJson) as unknown;
} catch {
return null;
}
}

View File

@@ -8,11 +8,12 @@ import {
GetMessageByIdQuery,
GetReactionsForMessageQuery,
GetUserQuery,
GetCurrentUserIdQuery,
GetRoomQuery,
GetBansForRoomQuery,
IsUserBannedQuery,
GetAttachmentsForMessageQuery
GetAttachmentsForMessageQuery,
GetPluginDataQuery,
GetMetaQuery
} from '../types';
import { handleGetMessages } from './handlers/getMessages';
import { handleGetMessagesSince } from './handlers/getMessagesSince';
@@ -28,6 +29,8 @@ import { handleGetBansForRoom } from './handlers/getBansForRoom';
import { handleIsUserBanned } from './handlers/isUserBanned';
import { handleGetAttachmentsForMessage } from './handlers/getAttachmentsForMessage';
import { handleGetAllAttachments } from './handlers/getAllAttachments';
import { handleGetPluginData } from './handlers/getPluginData';
import { handleGetMeta } from './handlers/getMeta';
export const buildQueryHandlers = (dataSource: DataSource): Record<QueryTypeKey, (query: Query) => Promise<unknown>> => ({
[QueryType.GetMessages]: (query) => handleGetMessages(query as GetMessagesQuery, dataSource),
@@ -43,5 +46,7 @@ export const buildQueryHandlers = (dataSource: DataSource): Record<QueryTypeKey,
[QueryType.GetBansForRoom]: (query) => handleGetBansForRoom(query as GetBansForRoomQuery, dataSource),
[QueryType.IsUserBanned]: (query) => handleIsUserBanned(query as IsUserBannedQuery, dataSource),
[QueryType.GetAttachmentsForMessage]: (query) => handleGetAttachmentsForMessage(query as GetAttachmentsForMessageQuery, dataSource),
[QueryType.GetAllAttachments]: () => handleGetAllAttachments(dataSource)
[QueryType.GetAllAttachments]: () => handleGetAllAttachments(dataSource),
[QueryType.GetPluginData]: (query) => handleGetPluginData(query as GetPluginDataQuery, dataSource),
[QueryType.GetMeta]: (query) => handleGetMeta(query as GetMetaQuery, dataSource)
});

View File

@@ -15,6 +15,9 @@ export const CommandType = {
RemoveBan: 'remove-ban',
SaveAttachment: 'save-attachment',
DeleteAttachmentsForMessage: 'delete-attachments-for-message',
SavePluginData: 'save-plugin-data',
DeletePluginData: 'delete-plugin-data',
SaveMeta: 'save-meta',
ClearAllData: 'clear-all-data'
} as const;
@@ -34,7 +37,9 @@ export const QueryType = {
GetBansForRoom: 'get-bans-for-room',
IsUserBanned: 'is-user-banned',
GetAttachmentsForMessage: 'get-attachments-for-message',
GetAllAttachments: 'get-all-attachments'
GetAllAttachments: 'get-all-attachments',
GetPluginData: 'get-plugin-data',
GetMeta: 'get-meta'
} as const;
export type QueryTypeKey = typeof QueryType[keyof typeof QueryType];
@@ -172,6 +177,16 @@ export interface AttachmentPayload {
savedPath?: string;
}
export type PluginDataScopePayload = 'local' | 'server';
export interface PluginDataPayload {
key: string;
pluginId: string;
scope: PluginDataScopePayload;
serverId?: string;
value: unknown;
}
export interface SaveMessageCommand { type: typeof CommandType.SaveMessage; payload: { message: MessagePayload } }
export interface DeleteMessageCommand { type: typeof CommandType.DeleteMessage; payload: { messageId: string } }
export interface UpdateMessageCommand { type: typeof CommandType.UpdateMessage; payload: { messageId: string; updates: Partial<MessagePayload> } }
@@ -188,6 +203,9 @@ export interface SaveBanCommand { type: typeof CommandType.SaveBan; payload: { b
export interface RemoveBanCommand { type: typeof CommandType.RemoveBan; payload: { oderId: string } }
export interface SaveAttachmentCommand { type: typeof CommandType.SaveAttachment; payload: { attachment: AttachmentPayload } }
export interface DeleteAttachmentsForMessageCommand { type: typeof CommandType.DeleteAttachmentsForMessage; payload: { messageId: string } }
export interface SavePluginDataCommand { type: typeof CommandType.SavePluginData; payload: PluginDataPayload }
export interface DeletePluginDataCommand { type: typeof CommandType.DeletePluginData; payload: Omit<PluginDataPayload, 'value'> }
export interface SaveMetaCommand { type: typeof CommandType.SaveMeta; payload: { key: string; value: string | null } }
export interface ClearAllDataCommand { type: typeof CommandType.ClearAllData; payload: Record<string, never> }
export type Command =
@@ -207,6 +225,9 @@ export type Command =
| RemoveBanCommand
| SaveAttachmentCommand
| DeleteAttachmentsForMessageCommand
| SavePluginDataCommand
| DeletePluginDataCommand
| SaveMetaCommand
| ClearAllDataCommand;
export interface GetMessagesQuery { type: typeof QueryType.GetMessages; payload: { roomId: string; limit?: number; offset?: number } }
@@ -223,6 +244,8 @@ export interface GetBansForRoomQuery { type: typeof QueryType.GetBansForRoom; pa
export interface IsUserBannedQuery { type: typeof QueryType.IsUserBanned; payload: { userId: string; roomId: string } }
export interface GetAttachmentsForMessageQuery { type: typeof QueryType.GetAttachmentsForMessage; payload: { messageId: string } }
export interface GetAllAttachmentsQuery { type: typeof QueryType.GetAllAttachments; payload: Record<string, never> }
export interface GetPluginDataQuery { type: typeof QueryType.GetPluginData; payload: Omit<PluginDataPayload, 'value'> }
export interface GetMetaQuery { type: typeof QueryType.GetMeta; payload: { key: string } }
export type Query =
| GetMessagesQuery
@@ -238,4 +261,6 @@ export type Query =
| GetBansForRoomQuery
| IsUserBannedQuery
| GetAttachmentsForMessageQuery
| GetAllAttachmentsQuery;
| GetAllAttachmentsQuery
| GetPluginDataQuery
| GetMetaQuery;

View File

@@ -25,7 +25,8 @@ import {
ReactionEntity,
BanEntity,
AttachmentEntity,
MetaEntity
MetaEntity,
PluginDataEntity
} from './entities';
const projectRootDatabaseFilePath = path.join(__dirname, '..', '..', settings.databaseName);
@@ -51,7 +52,8 @@ export const AppDataSource = new DataSource({
ReactionEntity,
BanEntity,
AttachmentEntity,
MetaEntity
MetaEntity,
PluginDataEntity
],
migrations: [path.join(__dirname, 'migrations', '*.{ts,js}')],
synchronize: false,

View File

@@ -16,7 +16,8 @@ import {
ReactionEntity,
BanEntity,
AttachmentEntity,
MetaEntity
MetaEntity,
PluginDataEntity
} from '../entities';
import { settings } from '../settings';
@@ -171,7 +172,8 @@ export async function initializeDatabase(): Promise<void> {
ReactionEntity,
BanEntity,
AttachmentEntity,
MetaEntity
MetaEntity,
PluginDataEntity
],
migrations: [path.join(__dirname, '..', 'migrations', '*.js'), path.join(__dirname, '..', 'migrations', '*.ts')],
synchronize: false,

View File

@@ -0,0 +1,26 @@
import {
Column,
Entity,
PrimaryColumn
} from 'typeorm';
@Entity('plugin_data')
export class PluginDataEntity {
@PrimaryColumn('text')
pluginId!: string;
@PrimaryColumn('text')
scope!: string;
@PrimaryColumn('text')
serverId!: string;
@PrimaryColumn('text')
key!: string;
@Column('text')
valueJson!: string;
@Column('integer')
updatedAt!: number;
}

View File

@@ -10,3 +10,4 @@ export { ReactionEntity } from './ReactionEntity';
export { BanEntity } from './BanEntity';
export { AttachmentEntity } from './AttachmentEntity';
export { MetaEntity } from './MetaEntity';
export { PluginDataEntity } from './PluginDataEntity';

View File

@@ -0,0 +1,25 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class AddPluginData1000000000008 implements MigrationInterface {
name = 'AddPluginData1000000000008';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS "plugin_data" (
"pluginId" TEXT NOT NULL,
"scope" TEXT NOT NULL,
"serverId" TEXT NOT NULL DEFAULT '',
"key" TEXT NOT NULL,
"valueJson" TEXT NOT NULL,
"updatedAt" INTEGER NOT NULL,
PRIMARY KEY ("pluginId", "scope", "serverId", "key")
)
`);
await queryRunner.query(`CREATE INDEX IF NOT EXISTS "idx_plugin_data_plugin_scope" ON "plugin_data" ("pluginId", "scope")`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DROP INDEX IF EXISTS "idx_plugin_data_plugin_scope"`);
await queryRunner.query(`DROP TABLE IF EXISTS "plugin_data"`);
}
}