diff --git a/docs/plans/state.json b/docs/plans/state.json index 30c3e95..1818707 100644 --- a/docs/plans/state.json +++ b/docs/plans/state.json @@ -3724,6 +3724,19 @@ "docs/plans/state.json" ], "test_status": "pnpm test:run src/channels/line/adapter.test.ts src/channels/zalo/adapter.test.ts + pnpm typecheck passing" + }, + "line-zalo-channel-minio-overrides": { + "status": "completed", + "date": "2026-02-17", + "updated": "2026-02-17", + "summary": "Added channel-specific MinIO override configuration for LINE and Zalo (`line.minio`, `zalo.minio`) with fallback inheritance from `backup.minio`, enabling per-channel endpoint/credential/bucket/prefix control while preserving legacy shared-prefix behavior.", + "files_modified": [ + "src/config/schema.ts", + "src/config/schema.test.ts", + "src/daemon/channels.ts", + "docs/plans/state.json" + ], + "test_status": "pnpm test:run src/config/schema.test.ts src/daemon/channels.test.ts src/channels/line/adapter.test.ts src/channels/zalo/adapter.test.ts + pnpm typecheck passing" } }, "overall_progress": { diff --git a/src/config/schema.test.ts b/src/config/schema.test.ts index 86babc3..bd14fe8 100644 --- a/src/config/schema.test.ts +++ b/src/config/schema.test.ts @@ -790,6 +790,44 @@ describe('configSchema — line', () => { expect(result.line.allowed_source_ids).toEqual([]); expect(result.line.require_mention).toBe(true); expect(result.line.mention_name).toBe('flynn'); + expect(result.line.minio.enabled).toBe(false); + expect(result.line.minio.prefix).toBe('flynn/channels/line'); + expect(result.line.minio.secure).toBe(true); + expect(result.line.minio.expires).toBe('24h'); + }); + + it('accepts line-specific minio overrides', () => { + const result = configSchema.parse({ + ...minimalConfig, + line: { + channel_access_token: 'line-token', + channel_secret: 'line-secret', + minio: { + enabled: true, + endpoint: 'localhost:9000', + access_key: 'line-key', + secret_key: 'line-secret', + bucket: 'line-attachments', + prefix: 'line/files', + secure: false, + expires: '12h', + mc_path: '/usr/local/bin/mc', + }, + }, + }); + + if (!result.line) { + throw new Error('Expected line config'); + } + expect(result.line.minio.enabled).toBe(true); + expect(result.line.minio.endpoint).toBe('localhost:9000'); + expect(result.line.minio.access_key).toBe('line-key'); + expect(result.line.minio.secret_key).toBe('line-secret'); + expect(result.line.minio.bucket).toBe('line-attachments'); + expect(result.line.minio.prefix).toBe('line/files'); + expect(result.line.minio.secure).toBe(false); + expect(result.line.minio.expires).toBe('12h'); + expect(result.line.minio.mc_path).toBe('/usr/local/bin/mc'); }); }); @@ -842,6 +880,43 @@ describe('configSchema — zalo', () => { expect(result.zalo.allowed_user_ids).toEqual([]); expect(result.zalo.require_mention).toBe(true); expect(result.zalo.mention_name).toBe('flynn'); + expect(result.zalo.minio.enabled).toBe(false); + expect(result.zalo.minio.prefix).toBe('flynn/channels/zalo'); + expect(result.zalo.minio.secure).toBe(true); + expect(result.zalo.minio.expires).toBe('24h'); + }); + + it('accepts zalo-specific minio overrides', () => { + const result = configSchema.parse({ + ...minimalConfig, + zalo: { + oa_access_token: 'oa-token', + minio: { + enabled: true, + endpoint: 'localhost:9000', + access_key: 'zalo-key', + secret_key: 'zalo-secret', + bucket: 'zalo-attachments', + prefix: 'zalo/files', + secure: false, + expires: '8h', + mc_path: '/usr/local/bin/mc', + }, + }, + }); + + if (!result.zalo) { + throw new Error('Expected zalo config'); + } + expect(result.zalo.minio.enabled).toBe(true); + expect(result.zalo.minio.endpoint).toBe('localhost:9000'); + expect(result.zalo.minio.access_key).toBe('zalo-key'); + expect(result.zalo.minio.secret_key).toBe('zalo-secret'); + expect(result.zalo.minio.bucket).toBe('zalo-attachments'); + expect(result.zalo.minio.prefix).toBe('zalo/files'); + expect(result.zalo.minio.secure).toBe(false); + expect(result.zalo.minio.expires).toBe('8h'); + expect(result.zalo.minio.mc_path).toBe('/usr/local/bin/mc'); }); }); diff --git a/src/config/schema.ts b/src/config/schema.ts index 49675a4..00c151c 100644 --- a/src/config/schema.ts +++ b/src/config/schema.ts @@ -595,6 +595,17 @@ const lineSchema = z.object({ allowed_source_ids: z.array(z.string()).default([]), require_mention: z.boolean().default(true), mention_name: z.string().default('flynn'), + minio: z.object({ + enabled: z.boolean().default(false), + endpoint: z.string().optional(), + access_key: z.string().optional(), + secret_key: z.string().optional(), + bucket: z.string().optional(), + prefix: z.string().default('flynn/channels/line'), + secure: z.boolean().default(true), + expires: z.string().default('24h'), + mc_path: z.string().optional(), + }).default({}), }).optional(); const feishuSchema = z.object({ @@ -614,6 +625,17 @@ const zaloSchema = z.object({ allowed_user_ids: z.array(z.string()).default([]), require_mention: z.boolean().default(true), mention_name: z.string().default('flynn'), + minio: z.object({ + enabled: z.boolean().default(false), + endpoint: z.string().optional(), + access_key: z.string().optional(), + secret_key: z.string().optional(), + bucket: z.string().optional(), + prefix: z.string().default('flynn/channels/zalo'), + secure: z.boolean().default(true), + expires: z.string().default('24h'), + mc_path: z.string().optional(), + }).default({}), }).optional(); const browserSchema = z.object({ diff --git a/src/daemon/channels.ts b/src/daemon/channels.ts index 7c7052a..c79db2f 100644 --- a/src/daemon/channels.ts +++ b/src/daemon/channels.ts @@ -26,6 +26,8 @@ function resolveChannelMinioShare(config: Config): { bucket: string; prefix: string; secure: boolean; + expires?: string; + mcPath?: string; } | undefined { const minio = config.backup.minio; if (!minio.enabled || !minio.endpoint || !minio.access_key || !minio.secret_key || !minio.bucket) { @@ -42,6 +44,55 @@ function resolveChannelMinioShare(config: Config): { }; } +function resolveChannelMinioShareWithOverrides( + base: ReturnType, + override: { + enabled: boolean; + endpoint?: string; + access_key?: string; + secret_key?: string; + bucket?: string; + prefix: string; + secure: boolean; + expires: string; + mc_path?: string; + } | undefined, +): { + enabled: boolean; + endpoint: string; + accessKey: string; + secretKey: string; + bucket: string; + prefix: string; + secure: boolean; + expires?: string; + mcPath?: string; +} | undefined { + if (!override?.enabled) { + return base; + } + + const endpoint = override.endpoint ?? base?.endpoint; + const accessKey = override.access_key ?? base?.accessKey; + const secretKey = override.secret_key ?? base?.secretKey; + const bucket = override.bucket ?? base?.bucket; + if (!endpoint || !accessKey || !secretKey || !bucket) { + return undefined; + } + + return { + enabled: true, + endpoint, + accessKey, + secretKey, + bucket, + prefix: override.prefix, + secure: override.secure, + expires: override.expires, + mcPath: override.mc_path, + }; +} + export function registerChannels(deps: ChannelsDeps): ChannelsResult { const { config, channelRegistry, hookEngine, pairingManager, gateway } = deps; const channelMinioShare = resolveChannelMinioShare(config); @@ -181,15 +232,17 @@ export function registerChannels(deps: ChannelsDeps): ChannelsResult { // Register LINE adapter (if configured) if (config.line) { + const lineMinioShare = resolveChannelMinioShareWithOverrides(channelMinioShare, config.line.minio); + const effectiveLineMinioShare = lineMinioShare && !config.line.minio.enabled + ? { ...lineMinioShare, prefix: `${lineMinioShare.prefix.replace(/\/+$/, '')}/channels/line` } + : lineMinioShare; const lineAdapter = new LineAdapter({ channelAccessToken: config.line.channel_access_token, channelSecret: config.line.channel_secret, allowedSourceIds: config.line.allowed_source_ids.length > 0 ? config.line.allowed_source_ids : undefined, requireMention: config.line.require_mention, mentionName: config.line.mention_name, - minio: channelMinioShare - ? { ...channelMinioShare, prefix: `${channelMinioShare.prefix.replace(/\/+$/, '')}/channels/line` } - : undefined, + minio: effectiveLineMinioShare, }); channelRegistry.register(lineAdapter); gateway.setLineHandler(lineAdapter); @@ -212,6 +265,10 @@ export function registerChannels(deps: ChannelsDeps): ChannelsResult { // Register Zalo adapter (if configured) if (config.zalo) { + const zaloMinioShare = resolveChannelMinioShareWithOverrides(channelMinioShare, config.zalo.minio); + const effectiveZaloMinioShare = zaloMinioShare && !config.zalo.minio.enabled + ? { ...zaloMinioShare, prefix: `${zaloMinioShare.prefix.replace(/\/+$/, '')}/channels/zalo` } + : zaloMinioShare; const zaloAdapter = new ZaloAdapter({ oaAccessToken: config.zalo.oa_access_token, endpoint: config.zalo.endpoint, @@ -219,9 +276,7 @@ export function registerChannels(deps: ChannelsDeps): ChannelsResult { allowedUserIds: config.zalo.allowed_user_ids.length > 0 ? config.zalo.allowed_user_ids : undefined, requireMention: config.zalo.require_mention, mentionName: config.zalo.mention_name, - minio: channelMinioShare - ? { ...channelMinioShare, prefix: `${channelMinioShare.prefix.replace(/\/+$/, '')}/channels/zalo` } - : undefined, + minio: effectiveZaloMinioShare, }); channelRegistry.register(zaloAdapter); gateway.setZaloHandler(zaloAdapter);