diff --git a/integrations/minecraft/.env b/integrations/minecraft/.env index 963c684de1..9d6c73c004 100644 --- a/integrations/minecraft/.env +++ b/integrations/minecraft/.env @@ -3,6 +3,9 @@ OPENAI_API_KEY='' OPENAI_MODEL='deepseek-chat' OPENAI_REASONING_MODEL='deepseek-reasoner' +# Copy the WebSocket token from AIRI Settings -> Connection into .env.local. +AIRI_WS_TOKEN='' + BOT_USERNAME='' BOT_HOSTNAME='' BOT_PORT='' diff --git a/integrations/minecraft/README.md b/integrations/minecraft/README.md index c88bc948b0..e4f2e153df 100644 --- a/integrations/minecraft/README.md +++ b/integrations/minecraft/README.md @@ -32,6 +32,15 @@ Treat this service as a local-development and trusted-server tool only. 3. Edit `integrations/minecraft/.env.local`. + In AIRI, open **Settings -> Connection** and copy the WebSocket authentication token. + Set `AIRI_WS_TOKEN` to that token in `.env.local`: + + ```dotenv + AIRI_WS_TOKEN='paste-your-token-here' + ``` + + If the token changes, update `.env.local` and restart the bot. + 4. Start the service: ```bash diff --git a/integrations/minecraft/package.json b/integrations/minecraft/package.json index 5b4eb858c7..1e9a8c16f5 100644 --- a/integrations/minecraft/package.json +++ b/integrations/minecraft/package.json @@ -16,6 +16,7 @@ "@modelcontextprotocol/sdk": "catalog:", "@moeru/std": "catalog:", "@proj-airi/server-sdk": "workspace:*", + "@proj-airi/server-shared": "workspace:*", "@xsai/generate-text": "catalog:", "@xsai/shared-chat": "catalog:", "alien-signals": "catalog:", diff --git a/integrations/minecraft/src/airi/start-background-client.test.ts b/integrations/minecraft/src/airi/start-background-client.test.ts new file mode 100644 index 0000000000..210bdb57f3 --- /dev/null +++ b/integrations/minecraft/src/airi/start-background-client.test.ts @@ -0,0 +1,145 @@ +import { once } from 'node:events' + +import { Client, parseEvent } from '@proj-airi/server-sdk' +import { describe, expect, it, vi } from 'vitest' +import { WebSocketServer } from 'ws' + +import { startAiriClientConnection } from './start-background-client' + +function createLogger() { + const logger = { + log: vi.fn<(message: string) => void>(), + warn: vi.fn<(message: string) => void>(), + withFields: vi.fn<(fields: Record) => Parameters[1]['logger']>(), + } + logger.withFields.mockReturnValue(logger) + return logger +} + +describe('background AIRI connection', () => { + // https://github.com/moeru-ai/airi/issues/2071 + // ROOT CAUSE: + // Authentication errors used the network retry warning and a second generic warning. + // Classify server authentication errors and report token setup once per connection. + it.each(['invalid token', 'not authenticated', 'must authenticate before announcing'])( + 'explains how to correct %s (Issue #2071)', + async (message) => { + const error = new Error(message) + const logger = createLogger() + const client = { connect: vi.fn().mockRejectedValue(error) } + const lifecycle = startAiriClientConnection(client, { logger, url: 'ws://localhost:6121/ws' }) + + lifecycle.reportUnavailable(error) + await vi.waitFor(() => expect(logger.warn).toHaveBeenCalled()) + await Promise.resolve() + + expect(logger.warn).toHaveBeenCalledTimes(1) + expect(logger.warn.mock.calls[0][0]).toContain('Settings -> Connection') + expect(logger.warn.mock.calls[0][0]).toContain('AIRI_WS_TOKEN') + expect(logger.warn.mock.calls[0][0]).not.toContain('retrying in background') + expect(logger.withFields).toHaveBeenCalledWith({ url: 'ws://localhost:6121/ws', error: message }) + }, + ) + + it('keeps background retry guidance for network failures', async () => { + const logger = createLogger() + const lifecycle = startAiriClientConnection({ connect: () => new Promise(() => {}) }, { logger, url: 'ws://localhost:6121/ws' }) + lifecycle.reportUnavailable(new Error('ECONNREFUSED')) + lifecycle.reportUnavailable(new Error('ECONNREFUSED')) + + expect(logger.warn).toHaveBeenCalledTimes(1) + expect(logger.warn.mock.calls[0][0]).toContain('retrying in background') + expect(logger.warn.mock.calls[0][0]).not.toContain('AIRI_WS_TOKEN') + }) + + it('reports authentication failure after an earlier network failure', () => { + const logger = createLogger() + const lifecycle = startAiriClientConnection({ connect: () => new Promise(() => {}) }, { logger, url: 'ws://localhost:6121/ws' }) + lifecycle.reportUnavailable(new Error('ECONNREFUSED')) + lifecycle.reportUnavailable(new Error('invalid token')) + + expect(logger.warn).toHaveBeenCalledTimes(2) + expect(logger.warn.mock.calls[1][0]).toContain('AIRI_WS_TOKEN') + }) + + it('explains authentication when connect rejects without an error callback', async () => { + const logger = createLogger() + startAiriClientConnection({ connect: () => Promise.reject(new Error('invalid token')) }, { logger, url: 'ws://localhost:6121/ws' }) + + await vi.waitFor(() => expect(logger.warn).toHaveBeenCalledTimes(1)) + expect(logger.warn.mock.calls[0][0]).toContain('AIRI_WS_TOKEN') + }) + + it('reports a new authentication failure after the connection succeeds', async () => { + const connection = Promise.withResolvers() + const logger = createLogger() + const lifecycle = startAiriClientConnection({ connect: () => connection.promise }, { logger, url: 'ws://localhost:6121/ws' }) + lifecycle.reportUnavailable(new Error('invalid token')) + lifecycle.reportUnavailable(new Error('invalid token')) + expect(logger.warn).toHaveBeenCalledTimes(1) + + connection.resolve() + await vi.waitFor(() => expect(logger.log).toHaveBeenCalledTimes(1)) + lifecycle.reportUnavailable(new Error('invalid token')) + + expect(logger.warn).toHaveBeenCalledTimes(2) + expect(logger.warn.mock.calls[1][0]).toContain('AIRI_WS_TOKEN') + }) + + // https://github.com/moeru-ai/airi/issues/2071 + // ROOT CAUSE: + // The SDK reports protocol authentication errors through onError and connect rejection. + // Both paths now share token guidance and suppress duplicate authentication warnings. + it.each([ + { token: undefined, message: 'must authenticate before announcing', event: 'extension:module:announce' }, + { token: 'invalid-test-token', message: 'invalid token', event: 'module:authenticate' }, + ])('reports $message from a real WebSocket handshake (Issue #2071)', async ({ token, message, event }) => { + const server = new WebSocketServer({ host: '127.0.0.1', port: 0 }) + await once(server, 'listening') + const address = server.address() + if (!address || typeof address === 'string') + throw new Error('Expected a TCP listener') + + const received = vi.fn() + server.on('connection', (socket) => { + socket.on('message', (data) => { + received(parseEvent(data.toString()).type) + socket.send(JSON.stringify({ + type: 'error', + data: { message }, + metadata: { + source: { kind: 'plugin', plugin: { id: 'server' }, id: 'server-1' }, + event: { id: 'auth-error' }, + }, + })) + }) + }) + + const url = `ws://127.0.0.1:${address.port}/ws` + const logger = createLogger() + let lifecycle: ReturnType | undefined + const client = new Client({ + name: 'minecraft', + url, + token, + autoConnect: false, + autoReconnect: false, + heartbeat: false, + onError: error => lifecycle?.reportUnavailable(error), + }) + try { + lifecycle = startAiriClientConnection(client, { logger, url }) + await vi.waitFor(() => expect(client.connectionStatus).toBe('failed')) + expect(received).toHaveBeenCalledWith(event) + expect(logger.warn).toHaveBeenCalledTimes(1) + expect(logger.warn.mock.calls[0][0]).toContain('AIRI_WS_TOKEN') + expect(logger.log).not.toHaveBeenCalled() + } + finally { + client.close() + for (const socket of server.clients) + socket.terminate() + await new Promise((resolve, reject) => server.close(error => error ? reject(error) : resolve())) + } + }) +}) diff --git a/integrations/minecraft/src/airi/start-background-client.ts b/integrations/minecraft/src/airi/start-background-client.ts index 6dccd39c74..9586cc59eb 100644 --- a/integrations/minecraft/src/airi/start-background-client.ts +++ b/integrations/minecraft/src/airi/start-background-client.ts @@ -1,4 +1,5 @@ import { errorMessageFrom } from '@moeru/std' +import { isAuthenticationServerErrorMessage } from '@proj-airi/server-shared' interface AiriClientLike { connect: () => Promise @@ -10,13 +11,32 @@ interface LoggerLike { withFields: (fields: Record) => LoggerLike } +/** Starts a background connection and reports each failure category once until the connection succeeds. */ export function startAiriClientConnection(client: AiriClientLike, deps: { logger: LoggerLike url: string }) { let unavailableReported = false + let authenticationReported = false + + const reportAuthenticationFailure = (error: unknown) => { + const message = errorMessageFrom(error) ?? 'Unknown error' + if (!isAuthenticationServerErrorMessage(message)) + return false + + if (!authenticationReported) { + authenticationReported = true + deps.logger.withFields({ url: deps.url, error: message }).warn( + 'AIRI authentication failed. Copy the token from AIRI Settings -> Connection. Set AIRI_WS_TOKEN in integrations/minecraft/.env.local to that token. Then restart the bot.', + ) + } + return true + } const reportUnavailable = (error: unknown) => { + if (reportAuthenticationFailure(error)) + return + if (unavailableReported) return @@ -24,13 +44,13 @@ export function startAiriClientConnection(client: AiriClientLike, deps: { deps.logger.withFields({ url: deps.url, error: errorMessageFrom(error) ?? 'Unknown error', - }).warn('AIRI server is unavailable; continuing startup without AIRI and retrying in background') + }).warn('AIRI server is unavailable. Continuing startup without AIRI and retrying in background') } const reportDisconnected = () => { deps.logger.withFields({ url: deps.url, - }).warn('AIRI server connection closed; retrying in background') + }).warn('AIRI server connection closed. Retrying in background') } void client.connect() @@ -43,8 +63,12 @@ export function startAiriClientConnection(client: AiriClientLike, deps: { : 'Connected to AIRI server', ) unavailableReported = false + authenticationReported = false }) .catch((error) => { + if (reportAuthenticationFailure(error)) + return + deps.logger.withFields({ url: deps.url, error: errorMessageFrom(error) ?? 'Unknown error', diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 704c5905ba..9c7d48828c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -3728,6 +3728,9 @@ importers: '@proj-airi/server-sdk': specifier: workspace:* version: link:../../packages/server-sdk + '@proj-airi/server-shared': + specifier: workspace:* + version: link:../../packages/server-shared '@xsai/generate-text': specifier: 'catalog:' version: 0.5.1