UNPKG

apptise-core

Version:

Core library for Apptise unified notification system

439 lines 18.9 kB
import { NotificationPlugin } from '../base/plugin.js'; import { HttpClient } from '../utils/http-client.js'; import { ErrorType } from '../base/types.js'; /** * BlueSky notification plugin * * Supports the following URL formats: * - bsky://{user}:{password}@{host} * - bsky://{user}:{password}@{host}:{port} * - bskys://{user}:{password}@{host} * - bskys://{user}:{password}@{host}:{port} * * Where: * - user: BlueSky handle (e.g., user.bsky.social or user@domain.com) * - password: App password (not account password) * - host: PDS hostname (optional, defaults to bsky.social) * - port: Custom port (optional) * * @example * ```typescript * const plugin = new BlueSkyPlugin(); * const config = plugin.parseUrl('bsky://user.bsky.social:app-password@bsky.social'); * const result = await plugin.send(config, { * title: 'Test Title', * body: 'Test message content' * }); * ``` */ export class BlueSkyPlugin extends NotificationPlugin { // Service registration information registration = { serviceId: 'bluesky', protocols: ['bsky', 'bskys'], name: 'BlueSky', description: 'BlueSky social network notification service', version: '1.0.0', }; // Service configuration constants serviceConfig = { // Default PDS hostname defaultHostname: 'bsky.social', // Default ports defaultPort: 80, defaultSecurePort: 443, // API endpoints sessionEndpoint: '/xrpc/com.atproto.server.createSession', postEndpoint: '/xrpc/com.atproto.repo.createRecord', resolveHandleEndpoint: '/xrpc/com.atproto.identity.resolveHandle', describeRepoEndpoint: '/xrpc/com.atproto.repo.describeRepo', // Request configuration timeout: 30000, // 30 seconds userAgent: 'Apptise/1.0.0 BlueSky Plugin', // Message limits maxMessageLength: 300, // BlueSky character limit // Rate limiting maxRetries: 3, retryDelay: 1000, // 1 second }; // URL templates supported by BlueSky templates = [ '{schema}://{user}:{password}@{host}', '{schema}://{user}:{password}@{host}:{port}', ]; // Template tokens definition templateTokens = { user: { name: 'User Handle', type: 'string', required: true, regex: ['^[a-zA-Z0-9._-]+(?:\\.[a-zA-Z0-9._-]+)*(?:@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,})?$', 'i'], }, password: { name: 'App Password', type: 'string', required: true, private: true, }, host: { name: 'PDS Hostname', type: 'string', required: false, }, port: { name: 'Port', type: 'int', min: 1, max: 65535, }, }; /** * Parse BlueSky notification URL * * @param url - BlueSky notification URL * @returns Parsed plugin configuration */ parseUrl(url) { // Parse the URL using base method const parsedUrl = this.parseUrlBase(url); // Validate protocol if (!this.registration.protocols.includes(parsedUrl.protocol)) { throw this.createError(ErrorType.INVALID_URL, `Unsupported protocol: ${parsedUrl.protocol}. Supported protocols: ${this.registration.protocols.join(', ')}`); } // Determine if secure connection const secure = parsedUrl.protocol === 'bskys'; // Extract user and password from URL const user = parsedUrl.username; const password = parsedUrl.password; if (!user || !password) { throw this.createError(ErrorType.INVALID_URL, 'BlueSky URL must include both user handle and app password'); } // Validate user handle format const userRegex = new RegExp(this.templateTokens.user.regex[0], this.templateTokens.user.regex[1]); if (!userRegex.test(user)) { throw this.createError(ErrorType.INVALID_URL, `Invalid user handle format: ${user}`); } // Extract host and port const host = parsedUrl.hostname || this.serviceConfig.defaultHostname; const port = parsedUrl.port || (secure ? this.serviceConfig.defaultSecurePort : this.serviceConfig.defaultPort); // Validate port range const portNum = typeof port === 'string' ? parseInt(port, 10) : port; if (portNum < this.templateTokens.port.min || portNum > this.templateTokens.port.max) { throw this.createError(ErrorType.INVALID_URL, `Port must be between ${this.templateTokens.port.min} and ${this.templateTokens.port.max}`); } // Build API endpoint const protocol = secure ? 'https' : 'http'; const defaultPort = secure ? this.serviceConfig.defaultSecurePort : this.serviceConfig.defaultPort; const portSuffix = port !== defaultPort ? `:${port}` : ''; const apiEndpoint = `${protocol}://${host}${portSuffix}`; const config = { user, password, host, port, secure, apiEndpoint, }; return { serviceId: this.registration.serviceId, url, config, }; } /** * Send notification via BlueSky API * * @param config - Plugin configuration * @param message - Notification message * @returns Notification result */ async send(config, message) { const { result, duration } = await this.measureTime(async () => { return this.safeExecute(async () => { // Validate configuration if (!this.validateConfig(config)) { throw this.createError(ErrorType.VALIDATION_ERROR, 'Invalid BlueSky plugin configuration'); } // Validate message if (!this.validateMessage(message)) { throw this.createError(ErrorType.VALIDATION_ERROR, 'Invalid notification message'); } const { user, password, apiEndpoint } = config.config; // Step 1: Resolve handle to get DID first (to match Python implementation) const did = await this.resolveHandleToDidPublic(user); // Step 2: Authenticate using DID const sessionData = await this.createSession(apiEndpoint, did, password); // Step 3: Create and send post const postResult = await this.createPost(apiEndpoint, did, message, sessionData.accessJwt); return this.createSuccessResult(postResult); }); }); return result; } /** * Create BlueSky session (login) */ async createSession(apiEndpoint, identifier, password) { const loginUrl = `${apiEndpoint}${this.serviceConfig.sessionEndpoint}`; const payload = { identifier, password, }; // Log HTTP request details for equivalence testing console.log(`[APPTISE_HTTP_REQUEST] Method: POST`); console.log(`[APPTISE_HTTP_REQUEST] URL: ${loginUrl}`); console.log(`[APPTISE_HTTP_REQUEST] Headers: ${JSON.stringify({ 'Content-Type': 'application/json', 'User-Agent': this.serviceConfig.userAgent, })}`); console.log(`[APPTISE_HTTP_REQUEST] Data: ${JSON.stringify(payload)}`); console.log(`[APPTISE_HTTP_REQUEST] Timeout: ${this.serviceConfig.timeout}`); const response = await HttpClient.post(loginUrl, JSON.stringify(payload), { headers: { 'Content-Type': 'application/json', 'User-Agent': this.serviceConfig.userAgent, }, timeout: this.serviceConfig.timeout, }); // Log HTTP response details for equivalence testing console.log(`[APPTISE_HTTP_RESPONSE] Status: ${response.status}`); console.log(`[APPTISE_HTTP_RESPONSE] Headers: ${JSON.stringify(response.headers)}`); console.log(`[APPTISE_HTTP_RESPONSE] Content: ${JSON.stringify(response.data)}`); if (response.status !== 200) { throw this.createError(ErrorType.AUTHENTICATION_ERROR, `BlueSky authentication failed: HTTP ${response.status}`, undefined, { response: response.data }); } let sessionData; try { sessionData = typeof response.data === 'string' ? JSON.parse(response.data) : response.data; } catch (e) { throw this.createError(ErrorType.AUTHENTICATION_ERROR, 'Invalid JSON response from BlueSky API'); } if (!sessionData.accessJwt || !sessionData.refreshJwt) { throw this.createError(ErrorType.AUTHENTICATION_ERROR, 'Invalid session response from BlueSky API'); } return sessionData; } /** * Resolve handle to DID using public API (to match Python implementation) */ async resolveHandleToDidPublic(handle) { const resolveUrl = `https://public.api.bsky.app${this.serviceConfig.resolveHandleEndpoint}?handle=${handle}`; // Log HTTP request details for equivalence testing console.log(`[APPTISE_HTTP_REQUEST] Method: GET`); console.log(`[APPTISE_HTTP_REQUEST] URL: ${resolveUrl}`); console.log(`[APPTISE_HTTP_REQUEST] Headers: ${JSON.stringify({ 'Content-Type': 'application/x-www-form-urlencoded; charset=utf-8', })}`); console.log(`[APPTISE_HTTP_REQUEST] Data: `); console.log(`[APPTISE_HTTP_REQUEST] Timeout: ${this.serviceConfig.timeout}`); const response = await HttpClient.get(resolveUrl, { headers: { 'Content-Type': 'application/x-www-form-urlencoded; charset=utf-8', }, timeout: this.serviceConfig.timeout, }); // Log HTTP response details for equivalence testing console.log(`[APPTISE_HTTP_RESPONSE] Status: ${response.status}`); console.log(`[APPTISE_HTTP_RESPONSE] Headers: ${JSON.stringify(response.headers)}`); console.log(`[APPTISE_HTTP_RESPONSE] Content: ${JSON.stringify(response.data)}`); if (response.status !== 200) { throw this.createError(ErrorType.NETWORK_ERROR, `Failed to resolve handle: HTTP ${response.status}`, undefined, { response: response.data }); } let resolveData; try { resolveData = typeof response.data === 'string' ? JSON.parse(response.data) : response.data; } catch (e) { throw this.createError(ErrorType.NETWORK_ERROR, 'Invalid JSON response from BlueSky API'); } if (!resolveData.did) { throw this.createError(ErrorType.NETWORK_ERROR, 'Invalid resolve handle response from BlueSky API'); } // Now resolve PDS endpoint from DID const plcUrl = `https://plc.directory/${resolveData.did}`; // Log HTTP request details for equivalence testing console.log(`[APPTISE_HTTP_REQUEST] Method: GET`); console.log(`[APPTISE_HTTP_REQUEST] URL: ${plcUrl}`); console.log(`[APPTISE_HTTP_REQUEST] Headers: ${JSON.stringify({ 'Content-Type': 'application/x-www-form-urlencoded; charset=utf-8', })}`); console.log(`[APPTISE_HTTP_REQUEST] Data: `); console.log(`[APPTISE_HTTP_REQUEST] Timeout: ${this.serviceConfig.timeout}`); const plcResponse = await HttpClient.get(plcUrl, { headers: { 'Content-Type': 'application/x-www-form-urlencoded; charset=utf-8', }, timeout: this.serviceConfig.timeout, }); // Log HTTP response details for equivalence testing console.log(`[APPTISE_HTTP_RESPONSE] Status: ${plcResponse.status}`); console.log(`[APPTISE_HTTP_RESPONSE] Headers: ${JSON.stringify(plcResponse.headers)}`); console.log(`[APPTISE_HTTP_RESPONSE] Content: ${JSON.stringify(plcResponse.data)}`); return resolveData.did; } /** * Resolve user handle to DID (legacy method) */ async resolveUserDid(apiEndpoint, handle, accessToken) { // If handle already looks like a DID, return it if (handle.startsWith('did:')) { return handle; } const resolveUrl = `${apiEndpoint}${this.serviceConfig.resolveHandleEndpoint}?handle=${encodeURIComponent(handle)}`; // Log HTTP request details for equivalence testing console.log(`[APPTISE_HTTP_REQUEST] Method: GET`); console.log(`[APPTISE_HTTP_REQUEST] URL: ${resolveUrl}`); console.log(`[APPTISE_HTTP_REQUEST] Headers: ${JSON.stringify({ 'Authorization': `Bearer ${accessToken}`, 'User-Agent': this.serviceConfig.userAgent, })}`); console.log(`[APPTISE_HTTP_REQUEST] Timeout: ${this.serviceConfig.timeout}`); const response = await HttpClient.get(resolveUrl, { headers: { 'Authorization': `Bearer ${accessToken}`, 'User-Agent': this.serviceConfig.userAgent, }, timeout: this.serviceConfig.timeout, }); // Log HTTP response details for equivalence testing console.log(`[APPTISE_HTTP_RESPONSE] Status: ${response.status}`); console.log(`[APPTISE_HTTP_RESPONSE] Headers: ${JSON.stringify(response.headers)}`); console.log(`[APPTISE_HTTP_RESPONSE] Content: ${JSON.stringify(response.data)}`); if (response.status !== 200) { throw this.createError(ErrorType.NETWORK_ERROR, `Failed to resolve handle: HTTP ${response.status}`, undefined, { response: response.data }); } const resolveData = response.data; if (!resolveData.did) { throw this.createError(ErrorType.NETWORK_ERROR, 'Invalid resolve handle response from BlueSky API'); } return resolveData.did; } /** * Create a post on BlueSky */ async createPost(apiEndpoint, did, message, accessToken) { const postUrl = `${apiEndpoint}${this.serviceConfig.postEndpoint}`; // Prepare post content - match Python behavior (combine title and body) let postText = ''; if (message.title) { postText = message.title; if (message.body) { postText += '\r\n' + message.body; } } else { postText = message.body || ''; } if (!postText) { throw this.createError(ErrorType.VALIDATION_ERROR, 'Message must have either title or body'); } // Truncate if too long if (postText.length > this.serviceConfig.maxMessageLength) { postText = postText.substring(0, this.serviceConfig.maxMessageLength - 3) + '...'; } const payload = { repo: did, collection: 'app.bsky.feed.post', record: { text: postText, createdAt: new Date().toISOString().replace(/\.\d{3}Z$/, 'Z'), $type: 'app.bsky.feed.post', }, }; // Log HTTP request details for equivalence testing console.log(`[APPTISE_HTTP_REQUEST] Method: POST`); console.log(`[APPTISE_HTTP_REQUEST] URL: ${postUrl}`); console.log(`[APPTISE_HTTP_REQUEST] Headers: ${JSON.stringify({ 'Content-Type': 'application/json', 'Authorization': `Bearer ${accessToken}`, 'User-Agent': this.serviceConfig.userAgent, })}`); console.log(`[APPTISE_HTTP_REQUEST] Data: ${JSON.stringify(payload)}`); console.log(`[APPTISE_HTTP_REQUEST] Timeout: ${this.serviceConfig.timeout}`); const response = await HttpClient.post(postUrl, JSON.stringify(payload), { headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${accessToken}`, 'User-Agent': this.serviceConfig.userAgent, }, timeout: this.serviceConfig.timeout, }); // Log HTTP response details for equivalence testing console.log(`[APPTISE_HTTP_RESPONSE] Status: ${response.status}`); console.log(`[APPTISE_HTTP_RESPONSE] Headers: ${JSON.stringify(response.headers)}`); console.log(`[APPTISE_HTTP_RESPONSE] Content: ${JSON.stringify(response.data)}`); if (response.status !== 200) { throw this.createError(ErrorType.NETWORK_ERROR, `Failed to create post: HTTP ${response.status}`, undefined, { response: response.data }); } return response.data; } /** * Validate BlueSky plugin configuration * * @param config - Plugin configuration to validate * @returns True if configuration is valid */ validateConfig(config) { if (!super.validateConfig(config)) { return false; } const { user, password, apiEndpoint, host, port, secure } = config.config; // Validate user handle if (!user || typeof user !== 'string' || user.trim().length === 0) { return false; } // Validate user handle format const userRegex = new RegExp(this.templateTokens.user.regex[0], this.templateTokens.user.regex[1]); if (!userRegex.test(user)) { return false; } // Validate password if (!password || typeof password !== 'string' || password.trim().length === 0) { return false; } // Validate API endpoint if (!apiEndpoint || typeof apiEndpoint !== 'string') { return false; } try { new URL(apiEndpoint); } catch { return false; } // Validate host (optional) if (host !== null && host !== undefined && (typeof host !== 'string' || host.trim().length === 0)) { return false; } // Validate port (optional) if (port !== null && port !== undefined && (typeof port !== 'number' || port < this.templateTokens.port.min || port > this.templateTokens.port.max)) { return false; } // Validate secure flag if (typeof secure !== 'boolean') { return false; } return true; } /** * Validate notification message for BlueSky * * @param message - Notification message to validate * @returns True if message is valid */ validateMessage(message) { if (!super.validateMessage(message)) { return false; } // BlueSky requires at least title or body if (!message.title && !message.body) { return false; } return true; } } // Export singleton instance export const blueSkyPlugin = new BlueSkyPlugin(); export default blueSkyPlugin; //# sourceMappingURL=bluesky.js.map