Skip to content
Open
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
13 changes: 13 additions & 0 deletions backend/__tests__/unit/integrations/manifestReadiness.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -65,4 +65,17 @@ describe('installable connector manifest readiness', () => {
expect(manifests.telegram.catalog.description).toBe('One Telegram chat, one pod.');
expect(manifests.telegram.catalog.description).not.toMatch(/ingest|summar/i);
});

it('ships the first-party locale copy beside each canonical catalog description', () => {
expect(manifests.telegram.catalog.descriptions).toEqual({
en: 'One Telegram chat, one pod.',
'zh-CN': '一个 Telegram 聊天,一个 Pod。',
});
expect(manifests.slack.catalog.descriptions).toEqual({
en: 'Your Slack DM, every pod you\'re in.',
'zh-CN': '你的 Slack 私信,你所在的每个 Pod。',
});
expect(manifests.discord.catalog.descriptions['zh-CN']).toBe('接入 Discord 频道动态,发布 Pod 摘要。');
expect(manifests.groupme.catalog.descriptions['zh-CN']).toBe('缓存 GroupMe 消息,汇总进 Pod。');
});
});
12 changes: 12 additions & 0 deletions backend/__tests__/unit/routes/installables.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,18 @@ describe('installable connector routes', () => {
expect(catalogService.catalogFor).toHaveBeenCalledWith('64b64c48c4f37a6b2f34c111');
});

it('does not let the browser Accept-Language header choose the catalog locale', async () => {
catalogService.catalogFor.mockResolvedValue({ installables: [] });

const res = await request(app)
.get('/api/installables')
.set(auth)
.set('Accept-Language', 'zh-CN,zh;q=0.9,en;q=0.8');

expect(res.status).toBe(200);
expect(catalogService.catalogFor).toHaveBeenCalledWith('64b64c48c4f37a6b2f34c111');
});

it('refuses an unavailable provider before reading a pod or claiming a parent', async () => {
catalogService.providerReadiness.mockReturnValue({ available: false, reason: 'not_configured' });

Expand Down
1 change: 1 addition & 0 deletions backend/__tests__/unit/scripts/seed-builtin-tools.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ describe('seed-builtin-tools', () => {
const rows = await Installable.find({ installableId: 'github' }).lean();
expect(rows).toHaveLength(1);
expect(rows[0].source).toBe('builtin');
expect(rows[0].descriptions['zh-CN']).toBe('装有 GitHub App 的那个仓库里的 issue 和 pull request,由 Commonly 的代理按 Pod 授权代为调用。');
expect(rows[0].components[0].enabledTools).toEqual(buildGithubToolInstallable().components[0].enabledTools);
});

Expand Down
61 changes: 61 additions & 0 deletions backend/__tests__/unit/services/installableCatalogService.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,10 @@ const githubTool = () => ({
installableId: 'github',
name: 'GitHub',
description: 'Issues and pull requests.',
descriptions: {
en: 'Issues and pull requests.',
'zh-CN': '问题和代码审查请求。',
},
components: [{ name: 'commonly-grant-broker', type: 'mcp-server', enabledTools: ['github.list_issues', 'github.create_issue'] }],
});

Expand Down Expand Up @@ -129,6 +133,58 @@ describe('installable catalog service', () => {
expect(Integration.find).not.toHaveBeenCalled();
});

it('projects the complete first-party description map and keeps canonical English', async () => {
mockInstallables([
{
installableId: 'telegram',
name: 'Telegram',
source: 'builtin',
description: 'One Telegram chat, one pod.',
descriptions: {
en: 'One Telegram chat, one pod.',
'zh-CN': '一个 Telegram 聊天,一个 Pod。',
},
},
{
installableId: 'slack',
name: 'Slack',
source: 'builtin',
description: 'Your Slack DM, every pod you\'re in.',
descriptions: { en: 'Your Slack DM, every pod you\'re in.' },
},
]);
InstallableInstallation.find.mockReturnValue(lean([]));

const catalog = await catalogFor(userId);
expect(catalog.installables.find((entry) => entry.installableId === 'telegram')).toEqual(expect.objectContaining({
description: 'One Telegram chat, one pod.',
descriptions: {
en: 'One Telegram chat, one pod.',
'zh-CN': '一个 Telegram 聊天,一个 Pod。',
},
}));
expect(catalog.installables.find((entry) => entry.installableId === 'slack')).toEqual(expect.objectContaining({
description: 'Your Slack DM, every pod you\'re in.',
descriptions: { en: 'Your Slack DM, every pod you\'re in.' },
}));
});

it('never translates a non-builtin row even when it carries a locale map', async () => {
mockInstallables([], [{
...githubTool(),
source: 'marketplace',
descriptions: { en: 'Marketplace English', 'zh-CN': '不应投影' },
}]);
InstallableInstallation.find.mockReturnValue(lean([]));
Integration.find.mockReturnValue(lean([]));

const catalog = await catalogFor(userId);
expect(catalog.installables.find((entry) => entry.list === 'tools')).toEqual(expect.objectContaining({
description: 'Issues and pull requests.',
descriptions: { en: 'Issues and pull requests.' },
}));
});

it('the catalogue returns the GitHub tool Installable with its tools, broker and the caller\'s connections, on the tools list', async () => {
process.env.GITHUB_APP_ID = 'app-1';
process.env.GITHUB_APP_PRIVATE_KEY = 'pem';
Expand All @@ -152,6 +208,10 @@ describe('installable catalog service', () => {
list: 'tools',
label: 'GitHub',
description: 'Issues and pull requests.',
descriptions: {
en: 'Issues and pull requests.',
'zh-CN': '问题和代码审查请求。',
},
available: true,
broker: { id: 'commonly-grant-broker' },
tools: [
Expand All @@ -162,6 +222,7 @@ describe('installable catalog service', () => {
installation: null,
integration: null,
});

expect(Integration.find).toHaveBeenCalledWith(expect.objectContaining({
type: { $in: ['github-app'] }, createdBy: userId, status: 'connected', revokedAt: null,
}));
Expand Down
4 changes: 4 additions & 0 deletions backend/__tests__/unit/services/toolInstallables.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,10 @@ describe('the builtin GitHub tool Installable', () => {

test('the builtin GitHub Installable enables exactly the broker\'s GitHub tools', () => {
const installable = buildGithubToolInstallable();
expect(installable.descriptions).toEqual({
en: installable.description,
'zh-CN': '装有 GitHub App 的那个仓库里的 issue 和 pull request,由 Commonly 的代理按 Pod 授权代为调用。',
});
const component = mcpComponentOf(installable);
expect(installable).toMatchObject({ installableId: 'github', source: 'builtin', kind: 'app', scope: 'pod', status: 'active' });
expect(installable.components).toHaveLength(1);
Expand Down
20 changes: 19 additions & 1 deletion backend/integrations/manifests.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ interface IntegrationManifest {
category: string;
docsPath: string;
description: string;
/** Locale-specific first-party copy; `description` remains the en source. */
descriptions?: Record<string, string>;
capabilities: string[];
} | null;
}
Expand Down Expand Up @@ -60,6 +62,10 @@ const manifests: Record<string, IntegrationManifest> = {
category: 'chat',
docsPath: 'docs/discord/DISCORD.md',
description: 'Ingest Discord channel activity and post pod summaries.',
descriptions: {
en: 'Ingest Discord channel activity and post pod summaries.',
'zh-CN': '接入 Discord 频道动态,发布 Pod 摘要。',
},
capabilities: ['webhook', 'gateway', 'summary', 'commands'],
},
}),
Expand All @@ -81,7 +87,11 @@ const manifests: Record<string, IntegrationManifest> = {
provider: 'slack',
category: 'chat',
docsPath: 'docs/slack/README.md',
description: 'Ingest Slack Events API messages into pod summaries.',
description: 'Your Slack DM, every pod you\'re in.',
descriptions: {
en: 'Your Slack DM, every pod you\'re in.',
'zh-CN': '你的 Slack 私信,你所在的每个 Pod。',
},
capabilities: ['webhook', 'summary', 'commands'],
},
}),
Expand All @@ -95,6 +105,10 @@ const manifests: Record<string, IntegrationManifest> = {
category: 'chat',
docsPath: 'docs/groupme/README.md',
description: 'Buffer GroupMe messages and summarize them into pods.',
descriptions: {
en: 'Buffer GroupMe messages and summarize them into pods.',
'zh-CN': '缓存 GroupMe 消息,汇总进 Pod。',
},
capabilities: ['webhook', 'commands', 'summary'],
},
}),
Expand All @@ -114,6 +128,10 @@ const manifests: Record<string, IntegrationManifest> = {
category: 'chat',
docsPath: 'docs/telegram/README.md',
description: 'One Telegram chat, one pod.',
descriptions: {
en: 'One Telegram chat, one pod.',
'zh-CN': '一个 Telegram 聊天,一个 Pod。',
},
capabilities: ['webhook', 'summary', 'commands'],
},
}),
Expand Down
18 changes: 18 additions & 0 deletions backend/models/Installable.ts
Original file line number Diff line number Diff line change
Expand Up @@ -278,6 +278,8 @@ export interface IInstallable extends Document {
installableId: string;
name: string;
description: string;
/** First-party locale copy; `description` remains the canonical English value. */
descriptions?: Record<string, string>;
version: string;

// Marketplace surface hint — which aisle to shelve this in. See
Expand Down Expand Up @@ -529,6 +531,22 @@ const InstallableSchema = new Schema<IInstallable>(
},
name: { type: String, required: true },
description: { type: String, required: true, default: '' },
descriptions: {
type: Schema.Types.Mixed,
default: undefined,
validate: {
validator: (value: unknown): boolean => (
value === undefined
|| (value !== null
&& typeof value === 'object'
&& !Array.isArray(value)
&& Object.entries(value as Record<string, unknown>).every(([locale, text]) => (
locale.trim().length > 0 && typeof text === 'string' && text.trim().length > 0
)))
),
message: 'descriptions must be a map of locale keys to non-empty strings',
},
},
version: { type: String, required: true },

kind: {
Expand Down
20 changes: 14 additions & 6 deletions backend/scripts/seed-builtin-connectors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ const TELEGRAM_CONNECTOR = {
// package wrapper, so it must not fork a second source of truth.
name: telegramCatalog.label,
description: telegramCatalog.description,
descriptions: telegramCatalog.descriptions,
version: '1.0.0',
kind: 'app',
source: 'builtin',
Expand Down Expand Up @@ -58,7 +59,8 @@ if (!slackCatalog) {
const SLACK_CONNECTOR = {
installableId: 'slack',
name: slackCatalog.label,
description: 'Link your Slack DM to Commonly — every pod you\'re in gets a voice where you already talk.',
description: slackCatalog.description,
descriptions: slackCatalog.descriptions,
version: '1.0.0',
kind: 'app',
source: 'builtin',
Expand Down Expand Up @@ -108,18 +110,24 @@ export const seedBuiltinConnectors = async (): Promise<void> => {
// create their uniqueness/TTL indexes during the deterministic boot seed
// rather than discovering a missing index on the first external request.
await Promise.all([ConnectorSecret.syncIndexes(), WebhookDelivery.syncIndexes()]);
await Promise.all([TELEGRAM_CONNECTOR, SLACK_CONNECTOR].map((connector) => (
Installable.findOneAndUpdate(
{ installableId: connector.installableId },
await Promise.all([TELEGRAM_CONNECTOR, SLACK_CONNECTOR].map(async (connector) => {
const holder = await Installable.findOne({ installableId: connector.installableId })
.select('source').lean() as { source?: string } | null;
if (holder && holder.source !== 'builtin') {
console.warn(`[builtin-connectors] installableId '${connector.installableId}' is held by a '${holder.source}' row; the builtin seed leaves it alone`);
return;
}
return Installable.findOneAndUpdate(
{ installableId: connector.installableId, source: 'builtin' },
{
$set: connector,
$setOnInsert: {
stats: { totalInstalls: 0, activeInstalls: 0, forkCount: 0 },
},
},
{ upsert: true, new: true, setDefaultsOnInsert: true },
)
)));
);
}));
console.log('[builtin-connectors] Telegram and Slack manifests ready');
} catch (error) {
console.error('[builtin-connectors] seed failed:', (error as Error).message);
Expand Down
43 changes: 40 additions & 3 deletions backend/services/installable/installableCatalogService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,36 @@ type ProviderManifest = {
readiness?: () => ProviderReadiness;
};

type LocalizedInstallable = {
description?: unknown;
descriptions?: unknown;
source?: string;
};

/**
* Project the locale map without choosing a language on the server. The page's
* in-app language is authoritative; `description` remains the canonical
* English fallback. Marketplace rows are never translated or persisted here.
*/
const projectDescriptions = (installable: LocalizedInstallable | null | undefined): Record<string, string> => {
const canonical = typeof installable?.description === 'string' ? installable.description : '';
if (installable?.source && installable.source !== 'builtin') return { en: canonical };

const raw = installable?.descriptions;
const entries = raw instanceof Map
? Array.from(raw.entries())
: raw && typeof raw === 'object' && !Array.isArray(raw)
? Object.entries(raw as Record<string, unknown>)
: [];
const projected = Object.fromEntries(entries.filter(([key, value]) => (
typeof key === 'string' && key.trim().length > 0
&& typeof value === 'string' && value.trim().length > 0
))) as Record<string, string>;
// Keep the canonical field authoritative if both fields contain English.
projected.en = canonical || projected.en || '';
return projected;
};

const providerInstallableIds = (): string[] => Object.values(manifests as Record<string, ProviderManifest>)
.filter((manifest) => typeof manifest.readiness === 'function')
.map((manifest) => manifest.id);
Expand Down Expand Up @@ -101,7 +131,8 @@ const toolEntriesFor = async (userId: string): Promise<unknown[]> => {
installableId: row.installableId,
list: 'tools',
label: row.name || row.installableId,
description: row.description || '',
description: typeof row.description === 'string' ? row.description : '',
descriptions: projectDescriptions(row),
available: readiness.available,
...(readiness.available ? {} : { unavailableReason: readiness.reason }),
broker: { id: String(component?.name || '') },
Expand Down Expand Up @@ -154,7 +185,8 @@ const catalogFor = async (userId: string): Promise<{ installables: unknown[] }>
installableId,
list: 'channels',
label: installable?.name || installableId,
description: installable?.description || '',
description: typeof installable?.description === 'string' ? installable.description : '',
descriptions: projectDescriptions(installable),
available: readiness.available,
...(readiness.reason ? { unavailableReason: readiness.reason } : {}),
installation: publicInstallation(installation),
Expand All @@ -166,6 +198,11 @@ const catalogFor = async (userId: string): Promise<{ installables: unknown[] }>
};
};

module.exports = { catalogFor, providerReadiness, publicIntegration };
module.exports = {
catalogFor,
providerReadiness,
publicIntegration,
projectDescriptions,
};

export {};
9 changes: 8 additions & 1 deletion backend/services/installable/toolInstallables.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,10 +68,17 @@ const githubToolNames = (): string[] => toolDefinitions()
.filter((definition) => definition.connectionType === 'github-app')
.map((definition) => definition.name);

const GITHUB_DESCRIPTION = 'Issues and pull requests in the repository the GitHub App is installed on, called through Commonly\'s broker on a room grant.';
const GITHUB_DESCRIPTIONS = {
en: GITHUB_DESCRIPTION,
'zh-CN': '装有 GitHub App 的那个仓库里的 issue 和 pull request,由 Commonly 的代理按 Pod 授权代为调用。',
};

export const buildGithubToolInstallable = () => ({
installableId: 'github',
name: 'GitHub',
description: 'Issues and pull requests in the repository the GitHub App is installed on, called through Commonly\'s broker on a room grant.',
description: GITHUB_DESCRIPTION,
descriptions: GITHUB_DESCRIPTIONS,
version: '1.0.0',
kind: 'app',
source: 'builtin',
Expand Down
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Loading