apptise-core
Version:
Core library for Apptise unified notification system
439 lines • 18.9 kB
JavaScript
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