feat(assistant): an admin can share their ChatGPT plan with everyone

The connection for everyone can now also be the admin's own ChatGPT sign-in.
Whether a plan may be shared that way is the self-hoster's call under their
plan and OpenAI's terms; the form says what it means before the sign-in.
The sign-in starts on the admin route and finishes on the shared one, which
refuses a sign-in for everyone to anyone who is not an admin. The ChatGPT
return reads the clipboard only once the paste handler exists.
This commit is contained in:
Maurice
2026-10-21 22:57:40 +02:00
parent e3d40ddcf6
commit 680c52dc85
38 changed files with 220 additions and 68 deletions
+4 -1
View File
@@ -155,7 +155,10 @@ export const aiApi = {
ackPrivacy: (body: AiPrivacyAckRequest): Promise<SuccessResponse> =>
apiClient.post('/ai/privacy-ack', body).then((r) => r.data),
chatgptStart: (): Promise<AiAuthStartResponse> => apiClient.post('/ai/connect/chatgpt/start', {}).then((r) => r.data),
chatgptStart: (scope: AiConnectionScope = 'user'): Promise<AiAuthStartResponse> =>
apiClient
.post(scope === 'instance' ? '/admin/ai/connect/chatgpt/start' : '/ai/connect/chatgpt/start', {})
.then((r) => r.data),
chatgptFinish: (body: AiChatgptFinishRequest): Promise<AiConnectionView> =>
apiClient.post('/ai/connect/chatgpt/finish', body, viaProvider).then((r) => r.data),
chatgptSignOut: (): Promise<SuccessResponse> =>
@@ -349,10 +349,14 @@ function PrivacyNote({ provider }: { provider: string }) {
);
}
/** The two ways each scope offers: ChatGPT is never an instance's, an own server never a user's (#1772). */
/**
* The ways each scope offers. An own server is never a user's (#1772). An
* admin may share their own ChatGPT plan with everyone: whether their plan and
* OpenAI's terms allow that is theirs to judge, and the form says so.
*/
const SCOPE_MODES: Record<AiConnectionScope, readonly AiConnectMode[]> = {
user: ['chatgpt', 'key'],
instance: ['key', 'custom'],
instance: ['key', 'chatgpt', 'custom'],
};
const MODE_TILES: Record<
@@ -392,7 +396,7 @@ export default function AiConnectPanel({ scope, onConnected, initialMode, prefil
const first = initialMode ?? prefillModeOf(prefill);
return first && SCOPE_MODES[scope].includes(first) ? first : null;
});
const chatgpt = useChatgptConnect({ active: scope === 'user' && mode === 'chatgpt', onConnected });
const chatgpt = useChatgptConnect({ active: mode === 'chatgpt', onConnected, scope });
const keyConnect = useKeyConnect({ scope, prefill, onConnected });
const custom = useCustomConnect({ prefill, onConnected });
const mcpEnabled = useAddonStore((s) => s.isEnabled('mcp'));
@@ -431,6 +435,16 @@ export default function AiConnectPanel({ scope, onConnected, initialMode, prefil
</div>
{scope === 'instance' && prefill && <SettingsHint>{t('assistant.admin.prefilled')}</SettingsHint>}
{mode === 'chatgpt' && scope === 'instance' && (
// Sharing one person's plan with everyone is the admin's call to make; TREK only says what it means.
<p
role="note"
className="m-0 rounded-lg bg-warning-soft px-3 py-2 leading-snug text-content"
style={fs(12, 'body')}
>
{t('assistant.admin.chatgptShared')}
</p>
)}
{mode === 'chatgpt' && <ChatGptPasteStep chatgpt={chatgpt} onOtherProvider={() => setMode('key')} />}
{mode === 'key' && <KeyForm scope={scope} keyConnect={keyConnect} />}
{mode === 'custom' && <CustomForm custom={custom} />}
@@ -157,7 +157,16 @@ export function useCustomConnect({
* page; pasting that address back finishes the sign-in at once, and coming
* back to this tab puts the cursor in the field.
*/
export function useChatgptConnect({ active, onConnected }: { active: boolean; onConnected: () => void }) {
export function useChatgptConnect({
active,
onConnected,
scope = 'user',
}: {
active: boolean;
onConnected: () => void;
/** The admin's sign-in shares their plan with everyone here. */
scope?: AiConnectionScope;
}) {
const { t } = useTranslation();
const toast = useToast();
const [attempt, setAttempt] = useState(0);
@@ -171,7 +180,7 @@ export function useChatgptConnect({ active, onConnected }: { active: boolean; on
useEffect(() => {
if (!active) return;
let cancelled = false;
void aiConnectionsRepo.chatgptStart().then(
void aiConnectionsRepo.chatgptStart(scope).then(
(next) => {
if (!cancelled) setFlow(next);
},
@@ -182,35 +191,7 @@ export function useChatgptConnect({ active, onConnected }: { active: boolean; on
return () => {
cancelled = true;
};
}, [active, attempt]);
// Back from the sign-in window: the address the user just copied is taken
// off the clipboard where the browser allows it, else the field has the cursor.
const takeCopied = useEffectEvent(() => {
if (pasted.trim() || busy) return;
if (!navigator.clipboard?.readText || !window.isSecureContext) {
inputRef.current?.focus();
return;
}
navigator.clipboard.readText().then(
(text) => {
if (!paste(text)) inputRef.current?.focus();
},
() => inputRef.current?.focus()
);
});
useEffect(() => {
if (!opened) return;
const onBack = () => {
if (document.visibilityState === 'visible') takeCopied();
};
document.addEventListener('visibilitychange', onBack);
window.addEventListener('focus', onBack);
return () => {
document.removeEventListener('visibilitychange', onBack);
window.removeEventListener('focus', onBack);
};
}, [opened]);
}, [active, attempt, scope]);
/** A fresh sign-in: the old one ran out or was used up. */
const restart = () => {
@@ -274,6 +255,34 @@ export function useChatgptConnect({ active, onConnected }: { active: boolean; on
);
};
// Back from the sign-in window: the address the user just copied is taken
// off the clipboard where the browser allows it, else the field has the cursor.
const takeCopied = useEffectEvent(() => {
if (pasted.trim() || busy) return;
if (!navigator.clipboard?.readText || !window.isSecureContext) {
inputRef.current?.focus();
return;
}
navigator.clipboard.readText().then(
(text) => {
if (!paste(text)) inputRef.current?.focus();
},
() => inputRef.current?.focus()
);
});
useEffect(() => {
if (!opened) return;
const onBack = () => {
if (document.visibilityState === 'visible') takeCopied();
};
document.addEventListener('visibilitychange', onBack);
window.addEventListener('focus', onBack);
return () => {
document.removeEventListener('visibilitychange', onBack);
window.removeEventListener('focus', onBack);
};
}, [opened]);
return {
flow,
opened,
+2 -2
View File
@@ -58,9 +58,9 @@ export const aiConnectionsRepo = {
await aiApi.ackPrivacy(body);
},
async chatgptStart() {
async chatgptStart(scope: AiConnectionScope = 'user') {
requireOnline();
return aiAuthStartResponseSchema.parse(await aiApi.chatgptStart());
return aiAuthStartResponseSchema.parse(await aiApi.chatgptStart(scope));
},
async chatgptFinish(body: AiChatgptFinishRequest) {
@@ -9,6 +9,7 @@ import { ManagedForbidden } from '../common/managed';
import { ResponseContract } from '../common/response-contract';
import { AiAdminSettingsDto, AiInstanceConnectionDto } from './ai-connections.dto';
import { AiConnectionsService } from './ai-connections.service';
import { ChatgptAuthService } from './chatgpt-auth.service';
import { OpenrouterAuthService } from './openrouter-auth.service';
import { Body, Controller, Delete, Get, HttpCode, Post, Put, UseGuards } from '@nestjs/common';
import {
@@ -34,6 +35,7 @@ export class AiAdminController {
constructor(
private readonly connections: AiConnectionsService,
private readonly openrouter: OpenrouterAuthService,
private readonly chatgpt: ChatgptAuthService,
) {}
@Get('settings')
@@ -81,4 +83,20 @@ export class AiAdminController {
startOpenrouter(@CurrentUser() user: User) {
return this.openrouter.start(user.id, 'instance');
}
/** The admin shares their own ChatGPT plan with everyone here; the sign-in finishes on the user route. */
@Post('connect/chatgpt/start')
@HttpCode(200)
@ResponseContract(aiAuthStartResponseSchema)
startChatgpt(@CurrentUser() user: User) {
return this.chatgpt.start(user.id, 'instance');
}
@Post('connect/chatgpt/sign-out')
@HttpCode(200)
@ResponseContract(aiAdminConnectionResponseSchema)
async signOutChatgpt(@CurrentUser() user: User) {
await this.chatgpt.signOut(user.id, 'instance');
return this.connections.instanceConnection();
}
}
@@ -7,6 +7,8 @@ export const AI_AUTH_FLOW_TTL_MS = 10 * 60_000;
export interface ChatgptAuthFlow {
provider: 'chatgpt';
userId: number;
/** A user's own plan, or the plan an admin shares with everyone on the instance. */
scope: AiConnectionScope;
verifier: string;
state: string;
nonce: string;
@@ -96,8 +96,9 @@ export class AiConnectionsController {
@HttpCode(200)
@ResponseContract(aiConnectionViewSchema)
async finishChatgpt(@CurrentUser() user: User, @Body() body: AiChatgptFinishDto) {
await this.chatgpt.finish(user.id, body.flowId, body.pastedUrl);
return this.connections.ownView(user.id);
// A sign-in an admin started for everyone answers with the instance's connection.
const scope = await this.chatgpt.finish(user, body.flowId, body.pastedUrl);
return scope === 'instance' ? this.connections.instanceView() : this.connections.ownView(user.id);
}
@Post('connect/chatgpt/sign-out')
@@ -243,6 +243,13 @@ export class AiConnectionsService implements SharedAiConnectionPort {
return saved;
}
/** The instance's connection as a user's own is shown, once a sign-in saved it. */
async instanceView(): Promise<AiConnectionView> {
const row = await this.connections.findInstance();
if (!row) throw aiRefusal('not_found', 'No connection for everyone yet');
return viewOf(row);
}
async instanceConnection(): Promise<AiAdminConnectionResponse> {
const row = await this.connections.findInstance();
return {
@@ -15,7 +15,7 @@ import { ModelCatalogService, pickDefaultModel } from './model-catalog.service';
import { AiProviderError, errorCodeOf } from './providers/provider-errors';
import { InjectRepository } from '@mikro-orm/nestjs';
import { Injectable } from '@nestjs/common';
import type { AiAuthStartResponse, AiModelInfo } from '@trek/shared';
import type { AiAuthStartResponse, AiConnectionScope, AiModelInfo } from '@trek/shared';
import { createHash, randomBytes, randomUUID } from 'node:crypto';
import { z } from 'zod';
@@ -150,9 +150,15 @@ export class ChatgptAuthService {
private readonly lock: AiRefreshLock,
) {}
async start(userId: number): Promise<AiAuthStartResponse> {
await this.settings.assertCanConnectOwn(userId);
const existing = await this.connections.findForUser(userId);
/**
* Starts a sign-in for the user's own connection, or for the instance's:
* an admin may share their plan with everyone here. Whether that suits
* their plan and OpenAI's terms is the admin's call; the setting says so.
*/
async start(userId: number, scope: AiConnectionScope = 'user'): Promise<AiAuthStartResponse> {
if (scope === 'user') await this.settings.assertCanConnectOwn(userId);
const existing =
scope === 'instance' ? await this.connections.findInstance() : await this.connections.findForUser(userId);
const savedClientId = existing?.kind === 'chatgpt' ? (this.accountOf(existing).clientId ?? null) : null;
const verifier = randomToken();
const state = randomToken();
@@ -173,7 +179,16 @@ export class ChatgptAuthService {
});
const flowId = randomUUID();
const expiresAt = Date.now() + AI_AUTH_FLOW_TTL_MS;
await this.flows.put(flowId, { provider: 'chatgpt', userId, verifier, state, nonce, savedClientId, expiresAt });
await this.flows.put(flowId, {
provider: 'chatgpt',
userId,
scope,
verifier,
state,
nonce,
savedClientId,
expiresAt,
});
return {
flowId,
authorizeUrl: `${AUTHORIZE_URL}?${params.toString()}`,
@@ -183,16 +198,18 @@ export class ChatgptAuthService {
/**
* Finishes a sign-in from the pasted callback address and saves the
* connection. The address is checked before the flow is taken, so a wrong
* paste can be corrected without starting over.
* connection, the user's or (started by an admin) the instance's; answers
* which. The address is checked before the flow is taken, so a wrong paste
* can be corrected without starting over.
*/
async finish(userId: number, flowId: string, pastedUrl: string): Promise<void> {
await this.settings.assertCanConnectOwn(userId);
async finish(user: { id: number; role: string }, flowId: string, pastedUrl: string): Promise<AiConnectionScope> {
const callback = readPastedCallback(pastedUrl);
const flow = await this.flows.take(flowId, userId, Date.now());
const flow = await this.flows.take(flowId, user.id, Date.now());
if (flow?.provider !== 'chatgpt' || callback.state !== flow.state) {
throw aiRefusal('expired', 'The sign-in took too long; start it again');
}
if (flow.scope === 'instance' && user.role !== 'admin') throw aiRefusal('forbidden', 'Admin access required');
if (flow.scope === 'user') await this.settings.assertCanConnectOwn(user.id);
if (callback.error) throw aiRefusal('generic', 'ChatGPT did not complete the sign-in');
// A first sign-in names the client id issued to this user's registration;
// a later one may leave it out, but must not name another.
@@ -214,7 +231,8 @@ export class ChatgptAuthService {
throw aiRefusal('generic', 'The sign-in could not be verified');
}
const existing = await this.connections.findForUser(userId);
const existing =
flow.scope === 'instance' ? await this.connections.findInstance() : await this.connections.findForUser(user.id);
const previous: AiConnectionAccount = existing?.kind === 'chatgpt' ? this.accountOf(existing) : {};
if (previous.sub && previous.sub !== claims.sub) {
throw aiRefusal('account_mismatch', 'This is another ChatGPT account; remove the connection first');
@@ -225,7 +243,7 @@ export class ChatgptAuthService {
}
const model = await this.modelFor(existing, tokens.access_token);
await this.connections.save(userId, {
await this.connections.save(flow.scope === 'instance' ? null : user.id, {
kind: 'chatgpt',
model,
baseUrl: null,
@@ -237,14 +255,16 @@ export class ChatgptAuthService {
hint: typeof claims.email === 'string' ? claims.email : null,
account: encodeJson(AI_CONNECTION_ACCOUNT, { clientId, sub: claims.sub, scopes }),
});
return flow.scope;
}
/**
* Signs the user out: the tokens go, the registration stays (the client id
* and the account), so signing in again is the same registration.
*/
async signOut(userId: number): Promise<void> {
const row = await this.connections.findForUser(userId);
async signOut(userId: number, scope: AiConnectionScope = 'user'): Promise<void> {
const row =
scope === 'instance' ? await this.connections.findInstance() : await this.connections.findForUser(userId);
if (row?.kind !== 'chatgpt') throw aiRefusal('not_found', 'No ChatGPT sign-in to sign out of');
await this.connections.clearSecrets(row.id, 'signed_out');
}
@@ -50,6 +50,7 @@ describe('InMemoryAiAuthFlowStore', () => {
const flow = (userId: number, expiresAt: number): ChatgptAuthFlow => ({
provider: 'chatgpt',
userId,
scope: 'user',
verifier: 'v',
state: 's',
nonce: 'n',
@@ -24,7 +24,7 @@ import { OidcService } from '../../../../src/nest/oidc/oidc.service';
import { createSnapshotTestDb } from '../../../helpers/db-mock';
import { findRow } from '../../../helpers/factories/rows';
import { readAppSetting } from '../../../helpers/factories/settings';
import { makeUser } from '../../../helpers/factories/users';
import { makeAdmin, makeUser } from '../../../helpers/factories/users';
import { resetTestDb } from '../../../helpers/test-db';
import { createTestModule, type TestModule } from '../../../helpers/test-module';
import { MikroORM } from '@mikro-orm/core';
@@ -100,8 +100,8 @@ async function refusal(promise: Promise<unknown>) {
}
/** Starts a sign-in and answers its flow id with the state and nonce the authorize URL carried. */
async function started(userId: number) {
const start = await service().start(userId);
async function started(userId: number, scope: 'user' | 'instance' = 'user') {
const start = await service().start(userId, scope);
const query = new URL(start.authorizeUrl).searchParams;
return { flowId: start.flowId, state: query.get('state') ?? '', nonce: query.get('nonce') ?? '', query };
}
@@ -206,12 +206,35 @@ describe('finishing a sign-in', () => {
expect(openSecrets(row?.secrets)).toMatchObject({ accessToken: access, refreshToken: 'refresh-1' });
});
it('CGPT-030: an admin shares their plan with everyone; only an admin finishes that sign-in', async () => {
const { user: admin } = await makeAdmin(orm());
const { user: member } = await makeUser(orm());
const shared = await started(admin.id, 'instance');
const url = callbackUrl({ code: 'c', state: shared.state, client_id: 'oaiapp_shared' });
// A flow belongs to whoever started it, so a member cannot even take it.
expect((await refusal(service().finish(member, shared.flowId, url))).body.code).toBe('expired');
safeFetch.mockResolvedValueOnce(
tokenAnswer(200, { access_token: 'at', refresh_token: 'rt', id_token: 'id', scope: PLAN_SCOPES }),
);
verifyIdToken.mockResolvedValueOnce(claims({ nonce: shared.nonce }));
expect(await service().finish(admin, shared.flowId, url)).toBe('instance');
expect(await findRow(orm(), AiConnections, { scope: 'instance' })).toMatchObject({ kind: 'chatgpt', user: null });
expect(await findRow(orm(), AiConnections, { user: admin.id })).toBeNull();
// An admin who is no longer one cannot finish a sign-in for everyone they started before.
const later = await started(admin.id, 'instance');
const demoted = { ...admin, role: 'user' };
const again = callbackUrl({ code: 'c', state: later.state, client_id: 'oaiapp_shared' });
expect((await refusal(service().finish(demoted, later.flowId, again))).body.code).toBe('forbidden');
});
it('CGPT-011: a wrong address is refused before the flow is used, so the right one still works', async () => {
const { user } = await makeUser(orm());
const flow = await started(user.id);
const wrong = await Promise.all(
['not a url', 'http://localhost:1455/auth/callback?code=x', 'https://chatgpt.com/'].map((pasted) =>
refusal(service().finish(user.id, flow.flowId, pasted)),
refusal(service().finish(user, flow.flowId, pasted)),
),
);
expect(wrong.map((r) => [r.status, r.body.code])).toEqual([
@@ -223,7 +246,7 @@ describe('finishing a sign-in', () => {
tokenAnswer(200, { access_token: 'at', refresh_token: 'rt', id_token: 'id', scope: PLAN_SCOPES }),
);
verifyIdToken.mockResolvedValueOnce(claims({ nonce: flow.nonce }));
await service().finish(user.id, flow.flowId, callbackUrl({ code: 'c', state: flow.state, client_id: 'oaiapp_x' }));
await service().finish(user, flow.flowId, callbackUrl({ code: 'c', state: flow.state, client_id: 'oaiapp_x' }));
expect(await findRow(orm(), AiConnections, { user: user.id })).not.toBeNull();
});
@@ -232,17 +255,17 @@ describe('finishing a sign-in', () => {
const { user: other } = await makeUser(orm());
const flow = await started(user.id);
const url = callbackUrl({ code: 'c', state: flow.state, client_id: 'oaiapp_x' });
expect((await refusal(service().finish(other.id, flow.flowId, url))).status).toBe(410);
expect((await refusal(service().finish(user.id, crypto.randomUUID(), url))).status).toBe(410);
expect((await refusal(service().finish(other, flow.flowId, url))).status).toBe(410);
expect((await refusal(service().finish(user, crypto.randomUUID(), url))).status).toBe(410);
expect(
(
await refusal(
service().finish(user.id, flow.flowId, callbackUrl({ code: 'c', state: 'other', client_id: 'oaiapp_x' })),
service().finish(user, flow.flowId, callbackUrl({ code: 'c', state: 'other', client_id: 'oaiapp_x' })),
)
).body,
).toMatchObject({ code: 'expired' });
// The mismatch used the flow up.
expect((await refusal(service().finish(user.id, flow.flowId, url))).status).toBe(410);
expect((await refusal(service().finish(user, flow.flowId, url))).status).toBe(410);
expect(safeFetch).not.toHaveBeenCalled();
});
@@ -252,7 +275,7 @@ describe('finishing a sign-in', () => {
['&client_id=evil', '&client_id=oaiapp_a&client_id=oaiapp_b'].map(async (extra) => {
const flow = await started(user.id);
return refusal(
service().finish(user.id, flow.flowId, `${CHATGPT_REDIRECT_URI}?code=c&state=${flow.state}${extra}`),
service().finish(user, flow.flowId, `${CHATGPT_REDIRECT_URI}?code=c&state=${flow.state}${extra}`),
);
}),
);
@@ -283,7 +306,7 @@ describe('finishing a sign-in', () => {
expect(
(
await refusal(
service().finish(user.id, flow.flowId, callbackUrl({ code: 'c', state: flow.state, client_id: 'oaiapp_x' })),
service().finish(user, flow.flowId, callbackUrl({ code: 'c', state: flow.state, client_id: 'oaiapp_x' })),
)
).status,
).toBe(400);
@@ -293,7 +316,7 @@ describe('finishing a sign-in', () => {
safeFetch.mockResolvedValueOnce(answer());
verifyIdToken.mockResolvedValueOnce(claims({ nonce: flow.nonce, sub: 'someone-else' }));
expect(
(await refusal(service().finish(user.id, flow.flowId, callbackUrl({ code: 'c', state: flow.state })))).body,
(await refusal(service().finish(user, flow.flowId, callbackUrl({ code: 'c', state: flow.state })))).body,
).toMatchObject({ code: 'account_mismatch' });
flow = await started(user.id);
@@ -302,7 +325,7 @@ describe('finishing a sign-in', () => {
);
verifyIdToken.mockResolvedValueOnce(claims({ nonce: flow.nonce }));
expect(
await refusal(service().finish(user.id, flow.flowId, callbackUrl({ code: 'c', state: flow.state }))),
await refusal(service().finish(user, flow.flowId, callbackUrl({ code: 'c', state: flow.state }))),
).toMatchObject({ status: 403, body: { code: 'not_eligible' } });
// The stored registration is untouched by any of them.
expect(openSecrets((await findRow(orm(), AiConnections, { user: user.id }))?.secrets)).toMatchObject({
@@ -317,7 +340,7 @@ describe('finishing a sign-in', () => {
expect(
(
await refusal(
service().finish(user.id, flow.flowId, callbackUrl({ code: 'c', state: flow.state, client_id: 'oaiapp_x' })),
service().finish(user, flow.flowId, callbackUrl({ code: 'c', state: flow.state, client_id: 'oaiapp_x' })),
)
).status,
).toBe(410);
@@ -326,7 +349,7 @@ describe('finishing a sign-in', () => {
expect(
(
await refusal(
service().finish(user.id, flow.flowId, callbackUrl({ code: 'c', state: flow.state, client_id: 'oaiapp_x' })),
service().finish(user, flow.flowId, callbackUrl({ code: 'c', state: flow.state, client_id: 'oaiapp_x' })),
)
).status,
).toBe(502);
+2
View File
@@ -511,5 +511,7 @@ const assistant: TranslationStrings = {
'assistant.tool.toggle_vacay_entry': 'تحديد يوم إجازة',
'assistant.tool.mark_country_visited': 'تحديد البلد كمُزار',
'assistant.tool.send_collab_message': 'إرسال إلى محادثة الرحلة',
'assistant.admin.chatgptShared':
'أنت تشارك اشتراكك الشخصي في ChatGPT مع الجميع على هذا الخادم: تُحتسب محادثاتهم من خطتك. تحقّق من أن خطتك وشروط OpenAI تسمح بذلك.',
};
export default assistant;
+2
View File
@@ -431,5 +431,7 @@ const assistant: TranslationStrings = {
'assistant.tool.toggle_vacay_entry': 'Məzuniyyət günü təyin et',
'assistant.tool.mark_country_visited': 'Ölkəni ziyarət edilmiş kimi işarələ',
'assistant.tool.send_collab_message': 'Səyahət söhbətinə göndər',
'assistant.admin.chatgptShared':
'Öz ChatGPT abunəliyini bu serverdəki hər kəslə paylaşırsan: onların söhbətləri sənin planından çıxılır. Planının və OpenAI şərtlərinin buna icazə verdiyini yoxla.',
};
export default assistant;
+2
View File
@@ -431,5 +431,7 @@ const assistant: TranslationStrings = {
'assistant.tool.toggle_vacay_entry': 'Lakaat un devezh vakañsoù',
'assistant.tool.mark_country_visited': 'Merkañ ar vro evel gweladennet',
'assistant.tool.send_collab_message': 'Kas d’ar gaozeadenn veaj',
'assistant.admin.chatgptShared':
'Rannañ a rez da goumanant ChatGPT gant an holl war ar servijer-mañ: o c’haozeadennoù a vez kontet war da goumanant. Gwir pe ha da goumanant ha divizoù OpenAI a aotre an dra-se.',
};
export default assistant;
+2
View File
@@ -434,5 +434,7 @@ const assistant: TranslationStrings = {
'assistant.tool.toggle_vacay_entry': 'Marca un dia de vacances',
'assistant.tool.mark_country_visited': 'Marca el país com a visitat',
'assistant.tool.send_collab_message': 'Envia al xat del viatge',
'assistant.admin.chatgptShared':
'Comparteixes el teu propi pla de ChatGPT amb tothom en aquest servidor: els seus xats compten contra el teu pla. Comprova que el teu pla i les condicions d’OpenAI ho permeten.',
};
export default assistant;
+2
View File
@@ -450,5 +450,7 @@ const assistant: TranslationStrings = {
'assistant.tool.toggle_vacay_entry': 'Nastavit den dovolené',
'assistant.tool.mark_country_visited': 'Označit zemi jako navštívenou',
'assistant.tool.send_collab_message': 'Poslat do chatu cesty',
'assistant.admin.chatgptShared':
'Sdílíš své vlastní předplatné ChatGPT se všemi na tomto serveru: jejich chaty se počítají do tvého tarifu. Ověř, že to tvůj tarif a podmínky OpenAI dovolují.',
};
export default assistant;
+2
View File
@@ -431,5 +431,7 @@ const assistant: TranslationStrings = {
'assistant.tool.toggle_vacay_entry': 'Urlaubstag setzen',
'assistant.tool.mark_country_visited': 'Land als besucht markieren',
'assistant.tool.send_collab_message': 'In den Trip-Chat senden',
'assistant.admin.chatgptShared':
'Du teilst dein eigenes ChatGPT-Abo mit allen auf diesem Server: Ihre Chats zählen gegen dein Kontingent. Prüfe, ob dein Abo und die Bedingungen von OpenAI das erlauben.',
};
export default assistant;
+2
View File
@@ -426,5 +426,7 @@ const assistant: TranslationStrings = {
'assistant.tool.toggle_vacay_entry': 'Set a vacation day',
'assistant.tool.mark_country_visited': 'Mark the country as visited',
'assistant.tool.send_collab_message': 'Send to the trip chat',
'assistant.admin.chatgptShared':
'You share your own ChatGPT plan with everyone on this server: their chats count against your plan. Check that your plan and OpenAI’s terms allow this.',
};
export default assistant;
+2
View File
@@ -429,5 +429,7 @@ const assistant: TranslationStrings = {
'assistant.tool.toggle_vacay_entry': 'Marcar un día de vacaciones',
'assistant.tool.mark_country_visited': 'Marcar el país como visitado',
'assistant.tool.send_collab_message': 'Enviar al chat del viaje',
'assistant.admin.chatgptShared':
'Compartes tu propio plan de ChatGPT con todos en este servidor: sus chats cuentan contra tu plan. Comprueba que tu plan y las condiciones de OpenAI lo permiten.',
};
export default assistant;
+2
View File
@@ -430,5 +430,7 @@ const assistant: TranslationStrings = {
'assistant.tool.toggle_vacay_entry': 'Määra puhkusepäev',
'assistant.tool.mark_country_visited': 'Märgi riik külastatuks',
'assistant.tool.send_collab_message': 'Saada reisivestlusesse',
'assistant.admin.chatgptShared':
'Jagad oma ChatGPT tellimust kõigiga selles serveris: nende vestlused arvestatakse sinu paketist. Kontrolli, kas sinu pakett ja OpenAI tingimused seda lubavad.',
};
export default assistant;
+2
View File
@@ -436,5 +436,7 @@ const assistant: TranslationStrings = {
'assistant.tool.toggle_vacay_entry': 'Poser un jour de congé',
'assistant.tool.mark_country_visited': 'Marquer le pays comme visité',
'assistant.tool.send_collab_message': 'Envoyer dans la discussion du voyage',
'assistant.admin.chatgptShared':
'Vous partagez votre propre abonnement ChatGPT avec tout le monde sur ce serveur : leurs discussions comptent sur votre forfait. Vérifiez que votre forfait et les conditions d’OpenAI le permettent.',
};
export default assistant;
+2
View File
@@ -435,5 +435,7 @@ const assistant: TranslationStrings = {
'assistant.tool.toggle_vacay_entry': 'Ορισμός ημέρας άδειας',
'assistant.tool.mark_country_visited': 'Σήμανση χώρας ως επισκεφθείσας',
'assistant.tool.send_collab_message': 'Αποστολή στη συνομιλία ταξιδιού',
'assistant.admin.chatgptShared':
'Μοιράζεσαι τη δική σου συνδρομή ChatGPT με όλους σε αυτόν τον διακομιστή: οι συνομιλίες τους χρεώνονται στο δικό σου πρόγραμμα. Έλεγξε ότι το πρόγραμμά σου και οι όροι της OpenAI το επιτρέπουν.',
};
export default assistant;
+2
View File
@@ -436,5 +436,7 @@ const assistant: TranslationStrings = {
'assistant.tool.toggle_vacay_entry': 'Szabadnap beállítása',
'assistant.tool.mark_country_visited': 'Ország megjelölése meglátogatottként',
'assistant.tool.send_collab_message': 'Küldés az utazási csevegésbe',
'assistant.admin.chatgptShared':
'A saját ChatGPT-előfizetésedet osztod meg mindenkivel ezen a szerveren: a csevegéseik a te keretedből fogynak. Ellenőrizd, hogy az előfizetésed és az OpenAI feltételei ezt megengedik-e.',
};
export default assistant;
+2
View File
@@ -411,5 +411,7 @@ const assistant: TranslationStrings = {
'assistant.tool.toggle_vacay_entry': 'Tetapkan hari cuti',
'assistant.tool.mark_country_visited': 'Tandai negara sebagai dikunjungi',
'assistant.tool.send_collab_message': 'Kirim ke obrolan perjalanan',
'assistant.admin.chatgptShared':
'Kamu membagikan paket ChatGPT milikmu kepada semua orang di server ini: obrolan mereka dihitung dari paketmu. Pastikan paketmu dan ketentuan OpenAI mengizinkannya.',
};
export default assistant;
+2
View File
@@ -437,5 +437,7 @@ const assistant: TranslationStrings = {
'assistant.tool.toggle_vacay_entry': 'Segna un giorno di ferie',
'assistant.tool.mark_country_visited': 'Segna il paese come visitato',
'assistant.tool.send_collab_message': 'Invia nella chat del viaggio',
'assistant.admin.chatgptShared':
'Condividi il tuo abbonamento ChatGPT con tutti su questo server: le loro chat contano sul tuo piano. Verifica che il tuo piano e i termini di OpenAI lo consentano.',
};
export default assistant;
+2
View File
@@ -406,5 +406,7 @@ const assistant: TranslationStrings = {
'assistant.tool.toggle_vacay_entry': '休暇日を設定',
'assistant.tool.mark_country_visited': '国を訪問済みにする',
'assistant.tool.send_collab_message': '旅行チャットに送信',
'assistant.admin.chatgptShared':
'自分の ChatGPT プランをこのサーバーの全員と共有します。全員のチャットがあなたのプランの利用枠を使います。プランと OpenAI の規約で許可されているか確認してください。',
};
export default assistant;
+2
View File
@@ -403,5 +403,7 @@ const assistant: TranslationStrings = {
'assistant.tool.toggle_vacay_entry': '휴가일 지정',
'assistant.tool.mark_country_visited': '국가를 방문함으로 표시',
'assistant.tool.send_collab_message': '여행 채팅으로 보내기',
'assistant.admin.chatgptShared':
'내 ChatGPT 요금제를 이 서버의 모든 사람과 공유합니다. 모든 사람의 채팅이 내 요금제 한도에서 차감됩니다. 요금제와 OpenAI 약관에서 허용되는지 확인하세요.',
};
export default assistant;
+2
View File
@@ -432,5 +432,7 @@ const assistant: TranslationStrings = {
'assistant.tool.toggle_vacay_entry': 'Vakantiedag instellen',
'assistant.tool.mark_country_visited': 'Land als bezocht markeren',
'assistant.tool.send_collab_message': 'Naar de reischat sturen',
'assistant.admin.chatgptShared':
'Je deelt je eigen ChatGPT-abonnement met iedereen op deze server: hun chats tellen mee voor jouw abonnement. Controleer of je abonnement en de voorwaarden van OpenAI dit toestaan.',
};
export default assistant;
+2
View File
@@ -474,5 +474,7 @@ const assistant: TranslationStrings = {
'assistant.tool.toggle_vacay_entry': 'Ustaw dzień urlopu',
'assistant.tool.mark_country_visited': 'Oznacz kraj jako odwiedzony',
'assistant.tool.send_collab_message': 'Wyślij na czat podróży',
'assistant.admin.chatgptShared':
'Udostępniasz własny plan ChatGPT wszystkim na tym serwerze: ich czaty liczą się do twojego planu. Sprawdź, czy twój plan i warunki OpenAI na to pozwalają.',
};
export default assistant;
+2
View File
@@ -474,5 +474,7 @@ const assistant: TranslationStrings = {
'assistant.tool.toggle_vacay_entry': 'Отметить день отпуска',
'assistant.tool.mark_country_visited': 'Отметить страну как посещённую',
'assistant.tool.send_collab_message': 'Отправить в чат поездки',
'assistant.admin.chatgptShared':
'Вы делитесь своей подпиской ChatGPT со всеми на этом сервере: их чаты расходуют ваш лимит. Проверьте, что ваш тариф и условия OpenAI это допускают.',
};
export default assistant;
+2
View File
@@ -450,5 +450,7 @@ const assistant: TranslationStrings = {
'assistant.tool.toggle_vacay_entry': 'Nastaviť deň dovolenky',
'assistant.tool.mark_country_visited': 'Označiť krajinu ako navštívenú',
'assistant.tool.send_collab_message': 'Poslať do chatu cesty',
'assistant.admin.chatgptShared':
'Zdieľaš svoje vlastné predplatné ChatGPT so všetkými na tomto serveri: ich chaty sa počítajú do tvojho plánu. Over, že to tvoj plán a podmienky OpenAI dovoľujú.',
};
export default assistant;
+2
View File
@@ -430,5 +430,7 @@ const assistant: TranslationStrings = {
'assistant.tool.toggle_vacay_entry': 'Sätt en semesterdag',
'assistant.tool.mark_country_visited': 'Markera landet som besökt',
'assistant.tool.send_collab_message': 'Skicka till reschatten',
'assistant.admin.chatgptShared':
'Du delar ditt eget ChatGPT-abonnemang med alla på den här servern: deras chattar räknas mot ditt abonnemang. Kontrollera att ditt abonnemang och OpenAIs villkor tillåter det.',
};
export default assistant;
+2
View File
@@ -403,5 +403,7 @@ const assistant: TranslationStrings = {
'assistant.tool.toggle_vacay_entry': 'กำหนดวันลา',
'assistant.tool.mark_country_visited': 'ทำเครื่องหมายว่าเคยไปประเทศนี้',
'assistant.tool.send_collab_message': 'ส่งไปยังแชตทริป',
'assistant.admin.chatgptShared':
'คุณกำลังแชร์แพ็กเกจ ChatGPT ของตัวเองกับทุกคนบนเซิร์ฟเวอร์นี้: แชตของทุกคนจะนับรวมในแพ็กเกจของคุณ ตรวจสอบว่าแพ็กเกจและข้อกำหนดของ OpenAI อนุญาต',
};
export default assistant;
+2
View File
@@ -430,5 +430,7 @@ const assistant: TranslationStrings = {
'assistant.tool.toggle_vacay_entry': 'İzin günü belirle',
'assistant.tool.mark_country_visited': 'Ülkeyi ziyaret edildi olarak işaretle',
'assistant.tool.send_collab_message': 'Gezi sohbetine gönder',
'assistant.admin.chatgptShared':
'Kendi ChatGPT aboneliğini bu sunucudaki herkesle paylaşıyorsun: sohbetleri senin planından düşer. Planının ve OpenAI koşullarının buna izin verip vermediğini kontrol et.',
};
export default assistant;
+2
View File
@@ -472,5 +472,7 @@ const assistant: TranslationStrings = {
'assistant.tool.toggle_vacay_entry': 'Позначити день відпустки',
'assistant.tool.mark_country_visited': 'Позначити країну як відвідану',
'assistant.tool.send_collab_message': 'Надіслати в чат подорожі',
'assistant.admin.chatgptShared':
'Ви ділитеся своєю підпискою ChatGPT з усіма на цьому сервері: їхні чати витрачають ваш ліміт. Перевірте, що ваш тариф і умови OpenAI це дозволяють.',
};
export default assistant;
+2
View File
@@ -406,5 +406,7 @@ const assistant: TranslationStrings = {
'assistant.tool.toggle_vacay_entry': 'Đặt ngày nghỉ phép',
'assistant.tool.mark_country_visited': 'Đánh dấu quốc gia đã đến',
'assistant.tool.send_collab_message': 'Gửi vào trò chuyện chuyến đi',
'assistant.admin.chatgptShared':
'Bạn đang chia sẻ gói ChatGPT của riêng mình với mọi người trên máy chủ này: các cuộc trò chuyện của họ được tính vào gói của bạn. Hãy kiểm tra gói của bạn và điều khoản của OpenAI có cho phép không.',
};
export default assistant;
+2
View File
@@ -400,5 +400,7 @@ const assistant: TranslationStrings = {
'assistant.tool.toggle_vacay_entry': '設為休假日',
'assistant.tool.mark_country_visited': '標記國家為已去過',
'assistant.tool.send_collab_message': '傳送到行程聊天',
'assistant.admin.chatgptShared':
'你正在與此伺服器上的所有人共用你自己的 ChatGPT 訂閱:他們的聊天會計入你的方案。請確認你的方案和 OpenAI 的條款允許這樣做。',
};
export default assistant;
+2
View File
@@ -400,5 +400,7 @@ const assistant: TranslationStrings = {
'assistant.tool.toggle_vacay_entry': '设为休假日',
'assistant.tool.mark_country_visited': '标记国家为已去过',
'assistant.tool.send_collab_message': '发送到行程聊天',
'assistant.admin.chatgptShared':
'你正在与此服务器上的所有人共享你自己的 ChatGPT 订阅:他们的聊天会计入你的套餐。请确认你的套餐和 OpenAI 的条款允许这样做。',
};
export default assistant;