refactor: Replace @atproto with @atcute for abstract/app api implementation

This commit is contained in:
FoxxMD
2026-06-04 17:50:40 +00:00
parent ff158c99cd
commit 41d181e02f
9 changed files with 175 additions and 112 deletions
+51 -39
View File
@@ -1,70 +1,82 @@
import { AbstractApiOptions } from "../../infrastructure/Atomic.js";
import { TealClientData } from "../../infrastructure/config/client/tealfm.js";
import { Agent, CredentialSession, AtpSessionEvent, AtpSessionData } from "@atproto/api";
import { AbstractATProtoApiClient } from "./AbstractATProtoApiClient.js";
import { getATProtoIdentifier, identifierToAtProtoHandle, isDID } from "./atUtils.js";
import { getATProtoIdentifier } from "./atUtils.js";
import { ATProtoAppData, ATProtoUserIdentifierData } from "../../infrastructure/config/client/atproto.js";
import { ATProtoAuthenticatedApiClient } from "./ATProtoAuthenticatedApiClient.js";
import { PasswordSession, PasswordSessionData } from '@atcute/password-session';
import { Client } from "@atcute/client";
export class ATProtoAppApiClient extends AbstractATProtoApiClient {
export class ATProtoAppApiClient extends ATProtoAuthenticatedApiClient {
declare config: ATProtoUserIdentifierData & ATProtoAppData;
appSession?: CredentialSession;
appPwAuth: boolean
constructor(name: any, config: TealClientData, options: AbstractApiOptions) {
super(name, config, options);
this.logger.verbose(`Using App Password auth for session`);
const cleanIdentifier = this.config.identifier;
if(isDID(cleanIdentifier)) {
this.logger.debug(`Identifier ${cleanIdentifier} looks like a DID, skipping parsing as a handle.`);
this.config.did = cleanIdentifier;
} else {
this.config.identifier = identifierToAtProtoHandle(this.config.identifier, {logger: this.logger, defaultDomain: 'bsky.social'});
}
if(this.config.appPassword === undefined) {
if (this.config.appPassword === undefined) {
throw new Error('Must provide app password');
}
this.logger.verbose(`Using App Password auth for session`);
}
async initClient(): Promise<void> {
const hd = await getATProtoIdentifier(this.config, {logger: this.logger, cache: this.cache.cacheAuth});
this.logger.verbose(`Using ${hd.did} on PDS ${hd.pds}`);
this.appSession = new CredentialSession(new URL(hd.pds), undefined, (evt: AtpSessionEvent, sess?: AtpSessionData) => {
this.cache.cacheAuth.set(`appPwSession-${this.name}-${hd.did}`, sess, '1000h');
});
this.agent = new Agent(this.appSession);
this.userData = await getATProtoIdentifier(this.config, { logger: this.logger, cache: this.cache.cacheAuth });
this.logger.verbose(`Using ${this.userData.did} on PDS ${this.userData.pds}`);
}
restoreSession = async (): Promise<boolean> => {
const hd = await getATProtoIdentifier(this.config, {logger: this.logger, cache: this.cache.cacheAuth});
const savedSession = await this.cache.cacheAuth.get<AtpSessionData>(`appPwSession-${this.name}-${hd.did}`);
if (savedSession !== undefined) {
const savedSessionCute = await this.getSession();
if (savedSessionCute !== undefined) {
const that = this;
try {
this.logger.debug('Found existing session, trying to resume...');
await this.appSession.resumeSession(savedSession);
this.logger.debug('Resumed session!');
return true;
const session = await PasswordSession.resume(savedSessionCute, {
async onUpdate(data) {
// called on login and token refresh — persist the session
await that.saveSession(data);
},
async onDelete(data) {
// called on logout or session invalidation — clean up
await that.deleteSession();
},
});
this.client = new Client({ handler: session });
} catch (e) {
this.logger.warn(new Error('Could not resume app password session from data', { cause: e }));
return false;
}
}
this.logger.debug('No app password session data to restore');
}
protected async saveSession(data: PasswordSessionData): Promise<void> {
await this.cache.cacheAuth.set(`appPwSessionCute-${this.name}-${this.userData.did}`, data, '1000h');
}
protected async getSession(): Promise<PasswordSessionData> {
return await this.cache.cacheAuth.get<PasswordSessionData>(`appPwSessionCute-${this.name}-${this.userData.did}`);
}
protected async deleteSession(): Promise<void> {
await this.cache.cacheAuth.delete(`appPwSessionCute-${this.name}-${this.userData.did}`);
}
appLogin = async (): Promise<boolean> => {
const that = this;
try {
const session = await PasswordSession.login(
{ service: this.userData.pds, identifier: this.userData.handle, password: this.config.appPassword },
{
//session: savedSession,
async onUpdate(data) {
// called on login and token refresh — persist the session
await that.saveSession(data);
},
async onDelete(data) {
// called on logout or session invalidation — clean up
await that.deleteSession();
},
},
);
const f = await this.appSession.login({
identifier: this.config.identifier,
password: this.config.appPassword
});
if (!f.success) {
this.logger.error('Login was not successful with app password');
return false;
}
this.logger.debug('Logged in.');
this.client = new Client({ handler: session });
return true;
} catch (e) {
this.logger.error(new Error('Could not login using app password', { cause: e }));
@@ -0,0 +1,5 @@
import { AbstractATProtoApiClient } from "./AbstractATProtoApiClient.js";
export abstract class ATProtoAuthenticatedApiClient extends AbstractATProtoApiClient {
abstract restoreSession(): Promise<boolean>;
}
+2 -3
View File
@@ -8,10 +8,9 @@ import {
OAuthSession,
} from "@atproto/oauth-client-node";
import { Agent } from "@atproto/api";
import { AbstractATProtoApiClient } from "./AbstractATProtoApiClient.js";
import { ATProtoAuthenticatedApiClient } from "./ATProtoAuthenticatedApiClient.js";
export class ATProtoOauthApiClient extends AbstractATProtoApiClient {
export class ATProtoOauthApiClient extends ATProtoAuthenticatedApiClient {
declare config: TealClientData;
@@ -0,0 +1,35 @@
import { UpstreamError } from "../../errors/UpstreamError.js";
import { HandleData } from "../../infrastructure/config/client/atproto.js";
import { AbstractATProtoApiClient } from "./AbstractATProtoApiClient.js";
import { getATProtoIdentifier, checkPds } from "./atUtils.js";
import { Client, simpleFetchHandler } from '@atcute/client';
import type {} from '@atcute/atproto';
import { Nsid } from "@atcute/lexicons";
export class ATProtoUnauthenticatedApiClient extends AbstractATProtoApiClient {
declare client: Client;
async initClient(): Promise<void> {
this.userData = await getATProtoIdentifier(this.config, {logger: this.logger, cache: this.cache.cacheAuth});
this.client = new Client({ handler: simpleFetchHandler({ service: this.userData.pds }) });
}
async listRecords(collection: string, options: {limit?: number, cursor?: string} = {}) {
const {limit = 20, cursor} = options;
try {
// records are returned newest to oldest
const response = await this.client.get('com.atproto.repo.listRecords', {
params: {
repo: this.userData.did,
collection: collection as Nsid,
limit,
cursor
}
});
return response;
} catch (e) {
throw new UpstreamError(`Failed to list scrobble record`, { cause: e, response: 'response' in e ? e.response : undefined });
}
}
}
+35 -37
View File
@@ -1,66 +1,64 @@
import { getRoot } from "../../../ioc.js";
import { AbstractApiOptions } from "../../infrastructure/Atomic.js";
import { TealClientData } from "../../infrastructure/config/client/tealfm.js";
import AbstractApiClient from "../AbstractApiClient.js";
import { Agent, ComAtprotoRepoListRecords } from "@atproto/api";
import { Agent } from "@atproto/api";
import { MSCache } from "../../Cache.js";
import { UpstreamError } from "../../errors/UpstreamError.js";
import { streamBodyProgress } from "../../../utils/NetworkUtils.js";
import { ATProtoUserIdentifierData } from "../../infrastructure/config/client/atproto.js";
import { getATProtoIdentifier, checkPds } from "./atUtils.js";
import { ATProtoUserIdentifierData, HandleData } from "../../infrastructure/config/client/atproto.js";
import { checkPds, isDID, identifierToAtProtoHandle } from "./atUtils.js";
import { Client, isXRPCErrorPayload } from '@atcute/client';
import { ComAtprotoSyncGetRepo } from '@atcute/atproto';
import { AtprotoDid } from "@atcute/lexicons/syntax";
export abstract class AbstractATProtoApiClient extends AbstractApiClient {
agent!: Agent;
declare config: ATProtoUserIdentifierData;
declare client: Client;
userData!: HandleData
cache: MSCache;
constructor(name: any, config: TealClientData, options: AbstractApiOptions) {
constructor(name: any, config: ATProtoUserIdentifierData, options: AbstractApiOptions) {
super('atproto', name, config, options);
this.cache = getRoot().items.cache();
const cleanIdentifier = this.config.identifier;
if(isDID(cleanIdentifier)) {
this.logger.debug(`Identifier ${cleanIdentifier} looks like a DID, skipping parsing as a handle.`);
this.config.did = cleanIdentifier;
} else {
this.config.identifier = identifierToAtProtoHandle(this.config.identifier, {logger: this.logger, defaultDomain: 'bsky.social'});
}
}
abstract initClient(): Promise<void>;
abstract restoreSession(): Promise<boolean>;
async listRecord(collection: string, options: {limit?: number, cursor?: string} = {}): Promise<ComAtprotoRepoListRecords.Response> {
const {limit = 20, cursor} = options;
try {
// records are returned newest to oldest
const response = await this.agent.com.atproto.repo.listRecords({
repo: this.agent.sessionManager.did,
collection,
limit,
cursor // cursor TID is EXCLUSIVE IE first record returned will be the first older than cursor
});
return response;
} catch (e) {
throw new UpstreamError(`Failed to list scrobble record`, { cause: e, response: 'response' in e ? e.response : undefined });
}
}
async checkPds(data: ATProtoUserIdentifierData): Promise<true> {
return await checkPds(data, {logger: this.logger, cache: this.cache.cacheAuth});
}
async getCAR() {
const resp = await this.agent.sessionManager.fetchHandler(`/xrpc/com.atproto.sync.getRepo?did=${encodeURIComponent(this.agent.sessionManager.did)}`, {
method: 'GET',
// @ts-expect-error
duplex: 'half',
redirect: 'follow',
headers: {
...(Object.fromEntries(this.agent.headers.entries())),
Accept: 'application/vnd.ipld.car',
}
async getCAR(did: AtprotoDid) {
const resp = await this.client.call(ComAtprotoSyncGetRepo, {
params: {
did
},
as: 'stream'
});
if(resp.status !== 200) {
const text = await resp.text();
if(!resp.ok) {
let text: string;
if(isXRPCErrorPayload(resp.data)) {
text = resp.data.error;
}
throw new UpstreamError(`Failed to fetch repo CAR file. Response was ${resp.status} with response ${text}`, {responseBody: text});
}
return await streamBodyProgress(resp, {
resp.headers
return await streamBodyProgress(resp.data, {
logger: this.logger,
chunkDefaultSize: 1024 * 1024 * 5, // report progress every 5 MB
fileHint: 'repo CAR'
+5 -3
View File
@@ -105,15 +105,17 @@ export const getATProtoIdentifier = async (data: ATProtoUserIdentifierData, opts
identifier
} = data;
assert(isAtprotoDid(givenDid), `Given DID is not an ATProto DID: ${givenDid}`);
let did: AtprotoDid = givenDid;
if (did === undefined) {
let did: AtprotoDid;
if (givenDid === undefined) {
try {
did = await handleResolver.resolve(identifier as `${string}.${string}`);
logger.debug(`Resolved ${did}`);
} catch (e) {
throw new Error('Unable to resolve handle', { cause: e });
}
} else {
assert(isAtprotoDid(givenDid), `Given DID is not an ATProto DID: ${givenDid}`);
did = givenDid;
}
const docResolver = new CompositeDidDocumentResolver({
+32 -13
View File
@@ -7,23 +7,24 @@ import { MSCache } from "../../Cache.js";
import { AbstractApiOptions, PagelessListensTimeRangeOptions, PagelessTimeRangeListens, PagelessTimeRangeListensResult } from "../../infrastructure/Atomic.js";
import { ListRecord, RecordOptions, TealClientData } from "../../infrastructure/config/client/tealfm.js";
import AbstractApiClient from "../AbstractApiClient.js";
import { AbstractATProtoApiClient } from "../atproto/AbstractATProtoApiClient.js";
import { ATProtoAppApiClient } from "../atproto/ATProtoAppApiClient.js";
import { ATProtoOauthApiClient } from "../atproto/ATProtoOauthApiClient.js";
import { Duration } from "dayjs/plugin/duration.js";
import { FmTealAlphaActorStatus, FmTealAlphaFeedPlay } from "./lexicons/index.js";
import { ScrobbleSubmitError } from "../../errors/MSErrors.js";
import { ComAtprotoRepoCreateRecord, ComAtprotoRepoPutRecord } from "@atproto/api";
import { getScrobbleTsSOCDateWithContext, usecToUnix } from "../../../utils/TimeUtils.js";
import { musicServiceToCononical } from "../listenbrainz/lzUtils.js";
import { parseRegexSingle } from "@foxxmd/regex-buddy-core";
import { decodeTid, generateTID } from "@ewanc26/tid";
import { ATProtoAuthenticatedApiClient } from "../atproto/ATProtoAuthenticatedApiClient.js";
import { UpstreamError } from "../../errors/UpstreamError.js";
import { ComAtprotoRepoCreateRecord, ComAtprotoRepoPutRecord } from '@atcute/atproto';
export class TealApiClient extends AbstractApiClient implements PagelessTimeRangeListens {
declare config: TealClientData;
declare client: AbstractATProtoApiClient;
declare client: ATProtoAuthenticatedApiClient;
cache: MSCache;
@@ -43,29 +44,35 @@ export class TealApiClient extends AbstractApiClient implements PagelessTimeRang
async createScrobbleRecord(record: FmTealAlphaFeedPlay.Main): Promise<ScrobbleActionResult> {
const input: ComAtprotoRepoCreateRecord.InputSchema = {
repo: this.client.agent.sessionManager.did,
collection: "fm.teal.alpha.feed.play",
const input: ComAtprotoRepoCreateRecord.$input = {
repo: this.client.userData.did,
collection: 'fm.teal.alpha.feed.play',
record
};
try {
const resp = await this.client.agent.com.atproto.repo.createRecord(input);
return {payload: input, response: resp.data};
const res = await this.client.client.post('com.atproto.repo.createRecord', {
input,
params: {}
});
return {payload: input, response: res.data};
} catch (e) {
throw new ScrobbleSubmitError(`Failed to create record for scrobble`, { cause: e, payload: input, response: 'response' in e ? e.response : undefined });
}
}
async updateStatusRecord(record: FmTealAlphaActorStatus.Main): Promise<ScrobbleActionResult> {
const input: ComAtprotoRepoPutRecord.InputSchema = {
repo: this.client.agent.sessionManager.did,
const input: ComAtprotoRepoPutRecord.$input = {
repo: this.client.userData.did,
collection: "fm.teal.alpha.actor.status",
rkey: "self",
record
};
try {
const resp = await this.client.agent.com.atproto.repo.putRecord(input);
return {payload: input, response: resp.data};
const res = await this.client.client.post('com.atproto.repo.putRecord', {
input,
params: {}
});
return {payload: input, response: res.data};
} catch (e) {
throw new ScrobbleSubmitError(`Failed to update status record for scrobble`, { cause: e, payload: input, response: 'response' in e ? e.response : undefined });
}
@@ -83,7 +90,19 @@ export class TealApiClient extends AbstractApiClient implements PagelessTimeRang
cursor = generateTID(dayjs.unix(to).toISOString());
}
const resp = await this.client.listRecord("fm.teal.alpha.feed.play", {cursor, limit});
const resp = await this.client.client.get('com.atproto.repo.listRecords', {
params: {
repo: this.client.userData.did,
collection: "fm.teal.alpha.feed.play",
limit,
cursor
}
});
if(!resp.ok) {
throw new UpstreamError('Fetching records from PDS failed', {cause: resp.data});
}
let fromTS: UnixTimestamp;
if(resp.data.cursor !== undefined) {
const { timestampUs } = decodeTid(resp.data.cursor);
+2 -11
View File
@@ -16,7 +16,6 @@ import { nowPlayingUpdateByPlayDuration, shouldClearNPStatus } from "./AbstractS
import { TealClientConfig } from "../common/infrastructure/config/client/tealfm.js";
import { ATProtoAppApiClient } from "../common/vendor/atproto/ATProtoAppApiClient.js";
import { ATProtoOauthApiClient } from "../common/vendor/atproto/ATProtoOauthApiClient.js";
import { AbstractATProtoApiClient } from "../common/vendor/atproto/AbstractATProtoApiClient.js";
import { playToRecord, TealApiClient } from "../common/vendor/teal/TealApiClient.js";
import { playToStatusRecord } from "../common/vendor/teal/TealApiClient.js";
import { nowPlayingExpirationDuration } from "../common/vendor/teal/TealApiClient.js";
@@ -48,14 +47,6 @@ export default class TealScrobbler extends AbstractHistoricalScrobbleClient {
this.scrobbleDelay = 1500;
this.supportsNowPlaying = true;
this.client = new TealApiClient(name, config.data, {...options, logger});
// if(config.data.appPassword !== undefined) {
// this.client = new BlueSkyAppApiClient(name, config.data, {...options, logger});
// this.requiresAuthInteraction = false;
// } else if(config.data.baseUri !== undefined) {
// this.client = new BlueSkyOauthApiClient(name, config.data, {...options, logger});
// } else {
// throw new Error(`Must define either 'baseUri' or 'appPassword' in configuration!`);
// }
this.nowPlayingMaxThreshold = nowPlayingUpdateByPlayDuration;
this.nowPlayingMinThreshold = (_) => 20;
this.configDir = options.configDir;
@@ -211,7 +202,7 @@ export default class TealScrobbler extends AbstractHistoricalScrobbleClient {
// TODO use `since` to get CAR diff instead of entire repo
// can use last import date from migrations table
const filename = path.resolve(this.configDir, `${this.getSafeExternalId()}-${dayjs().unix()}.car`);
await fsPromise.writeFile(filename, Buffer.from(((await this.client.client.getCAR()))));
await fsPromise.writeFile(filename, Buffer.from(((await this.client.client.getCAR(this.client.client.userData.did)))));
return filename;
}
@@ -227,7 +218,7 @@ export default class TealScrobbler extends AbstractHistoricalScrobbleClient {
await using repo = fromStream(stream);
const did = this.client?.client?.agent?.sessionManager?.did;
const did = this.client.client.userData.did;
let batch: RepositoryCreatePlayHistoricalOpts[] = [];
let allGood = true;
+8 -6
View File
@@ -272,24 +272,26 @@ export const wsReadyStateToStr = (state: number): string => {
export type StreamBodyOpts = {
logger?: Logger,
chunkDefaultSize?: number,
fileHint?: string
fileHint?: string,
headers?: Headers
}
export const streamBodyProgress = async (response: Response, opts: StreamBodyOpts = {}) => {
export const streamBodyProgress = async (stream: ReadableStream<Uint8Array<ArrayBufferLike>>, opts: StreamBodyOpts = {}) => {
const {
logger = loggerNoop,
chunkDefaultSize = 1024 * 1024 * 10, // default to every 10MB, when we don't know response size
fileHint = 'file'
fileHint = 'file',
headers
} = opts;
let loading = true,
chunks: any[] = [];
const reader = response.body.getReader();
const reader = stream.getReader();
let length: number,
chunkReportSize: number = chunkDefaultSize,
lastReportedSize: number = 0;
if(null !== response.headers.get('content-length')) {
length = +response.headers.get('content-length');
if(headers !== undefined && null !== headers.get('content-length')) {
length = +headers.get('content-length');
const [summary, size, unit] = formatBytes(length);
if(unit === 'MiB' && size > 10) {
switch(true) {