···11import { getRoot } from "../../../ioc.js";
22import { AbstractApiOptions } from "../../infrastructure/Atomic.js";
33-import { TealClientData } from "../../infrastructure/config/client/tealfm.js";
43import AbstractApiClient from "../AbstractApiClient.js";
55-import { Agent, ComAtprotoRepoListRecords } from "@atproto/api";
44+import { Agent } from "@atproto/api";
65import { MSCache } from "../../Cache.js";
76import { UpstreamError } from "../../errors/UpstreamError.js";
87import { streamBodyProgress } from "../../../utils/NetworkUtils.js";
99-import { ATProtoUserIdentifierData } from "../../infrastructure/config/client/atproto.js";
1010-import { getATProtoIdentifier, checkPds } from "./atUtils.js";
88+import { ATProtoUserIdentifierData, HandleData } from "../../infrastructure/config/client/atproto.js";
99+import { checkPds, isDID, identifierToAtProtoHandle } from "./atUtils.js";
1010+import { Client, isXRPCErrorPayload } from '@atcute/client';
1111+import { ComAtprotoSyncGetRepo } from '@atcute/atproto';
1212+import { AtprotoDid } from "@atcute/lexicons/syntax";
11131214export abstract class AbstractATProtoApiClient extends AbstractApiClient {
13151416 agent!: Agent;
15171616- cache: MSCache;
1818+ declare config: ATProtoUserIdentifierData;
17191818- constructor(name: any, config: TealClientData, options: AbstractApiOptions) {
1919- super('atproto', name, config, options);
2020+ declare client: Client;
20212121- this.cache = getRoot().items.cache();
2222- }
2222+ userData!: HandleData
23232424- abstract initClient(): Promise<void>;
2424+ cache: MSCache;
25252626- abstract restoreSession(): Promise<boolean>;
2626+ constructor(name: any, config: ATProtoUserIdentifierData, options: AbstractApiOptions) {
2727+ super('atproto', name, config, options);
2828+ this.cache = getRoot().items.cache();
27292828- async listRecord(collection: string, options: {limit?: number, cursor?: string} = {}): Promise<ComAtprotoRepoListRecords.Response> {
2929- const {limit = 20, cursor} = options;
3030- try {
3131- // records are returned newest to oldest
3232- const response = await this.agent.com.atproto.repo.listRecords({
3333- repo: this.agent.sessionManager.did,
3434- collection,
3535- limit,
3636- cursor // cursor TID is EXCLUSIVE IE first record returned will be the first older than cursor
3737- });
3838- return response;
3939- } catch (e) {
4040- throw new UpstreamError(`Failed to list scrobble record`, { cause: e, response: 'response' in e ? e.response : undefined });
3030+ const cleanIdentifier = this.config.identifier;
3131+ if(isDID(cleanIdentifier)) {
3232+ this.logger.debug(`Identifier ${cleanIdentifier} looks like a DID, skipping parsing as a handle.`);
3333+ this.config.did = cleanIdentifier;
3434+ } else {
3535+ this.config.identifier = identifierToAtProtoHandle(this.config.identifier, {logger: this.logger, defaultDomain: 'bsky.social'});
4136 }
4237 }
3838+3939+ abstract initClient(): Promise<void>;
43404441 async checkPds(data: ATProtoUserIdentifierData): Promise<true> {
4542 return await checkPds(data, {logger: this.logger, cache: this.cache.cacheAuth});
4643 }
47444848- async getCAR() {
4949- const resp = await this.agent.sessionManager.fetchHandler(`/xrpc/com.atproto.sync.getRepo?did=${encodeURIComponent(this.agent.sessionManager.did)}`, {
5050- method: 'GET',
5151- // @ts-expect-error
5252- duplex: 'half',
5353- redirect: 'follow',
5454- headers: {
5555- ...(Object.fromEntries(this.agent.headers.entries())),
5656- Accept: 'application/vnd.ipld.car',
5757- }
4545+ async getCAR(did: AtprotoDid) {
4646+ const resp = await this.client.call(ComAtprotoSyncGetRepo, {
4747+ params: {
4848+ did
4949+ },
5050+ as: 'stream'
5851 });
5959- if(resp.status !== 200) {
6060- const text = await resp.text();
5252+ if(!resp.ok) {
5353+ let text: string;
5454+ if(isXRPCErrorPayload(resp.data)) {
5555+ text = resp.data.error;
5656+ }
6157 throw new UpstreamError(`Failed to fetch repo CAR file. Response was ${resp.status} with response ${text}`, {responseBody: text});
6258 }
6363- return await streamBodyProgress(resp, {
5959+6060+ resp.headers
6161+ return await streamBodyProgress(resp.data, {
6462 logger: this.logger,
6563 chunkDefaultSize: 1024 * 1024 * 5, // report progress every 5 MB
6664 fileHint: 'repo CAR'
+5-3
src/backend/common/vendor/atproto/atUtils.ts
···105105 identifier
106106 } = data;
107107108108- assert(isAtprotoDid(givenDid), `Given DID is not an ATProto DID: ${givenDid}`);
109109- let did: AtprotoDid = givenDid;
110110- if (did === undefined) {
108108+ let did: AtprotoDid;
109109+ if (givenDid === undefined) {
111110 try {
112111 did = await handleResolver.resolve(identifier as `${string}.${string}`);
113112 logger.debug(`Resolved ${did}`);
114113 } catch (e) {
115114 throw new Error('Unable to resolve handle', { cause: e });
116115 }
116116+ } else {
117117+ assert(isAtprotoDid(givenDid), `Given DID is not an ATProto DID: ${givenDid}`);
118118+ did = givenDid;
117119 }
118120119121 const docResolver = new CompositeDidDocumentResolver({
+32-13
src/backend/common/vendor/teal/TealApiClient.ts
···77import { AbstractApiOptions, PagelessListensTimeRangeOptions, PagelessTimeRangeListens, PagelessTimeRangeListensResult } from "../../infrastructure/Atomic.js";
88import { ListRecord, RecordOptions, TealClientData } from "../../infrastructure/config/client/tealfm.js";
99import AbstractApiClient from "../AbstractApiClient.js";
1010-import { AbstractATProtoApiClient } from "../atproto/AbstractATProtoApiClient.js";
1110import { ATProtoAppApiClient } from "../atproto/ATProtoAppApiClient.js";
1211import { ATProtoOauthApiClient } from "../atproto/ATProtoOauthApiClient.js";
1312import { Duration } from "dayjs/plugin/duration.js";
1413import { FmTealAlphaActorStatus, FmTealAlphaFeedPlay } from "./lexicons/index.js";
1514import { ScrobbleSubmitError } from "../../errors/MSErrors.js";
1616-import { ComAtprotoRepoCreateRecord, ComAtprotoRepoPutRecord } from "@atproto/api";
1715import { getScrobbleTsSOCDateWithContext, usecToUnix } from "../../../utils/TimeUtils.js";
1816import { musicServiceToCononical } from "../listenbrainz/lzUtils.js";
1917import { parseRegexSingle } from "@foxxmd/regex-buddy-core";
2018import { decodeTid, generateTID } from "@ewanc26/tid";
1919+import { ATProtoAuthenticatedApiClient } from "../atproto/ATProtoAuthenticatedApiClient.js";
2020+import { UpstreamError } from "../../errors/UpstreamError.js";
2121+import { ComAtprotoRepoCreateRecord, ComAtprotoRepoPutRecord } from '@atcute/atproto';
21222223export class TealApiClient extends AbstractApiClient implements PagelessTimeRangeListens {
23242425 declare config: TealClientData;
25262626- declare client: AbstractATProtoApiClient;
2727+ declare client: ATProtoAuthenticatedApiClient;
27282829 cache: MSCache;
2930···434444454546 async createScrobbleRecord(record: FmTealAlphaFeedPlay.Main): Promise<ScrobbleActionResult> {
4646- const input: ComAtprotoRepoCreateRecord.InputSchema = {
4747- repo: this.client.agent.sessionManager.did,
4848- collection: "fm.teal.alpha.feed.play",
4747+ const input: ComAtprotoRepoCreateRecord.$input = {
4848+ repo: this.client.userData.did,
4949+ collection: 'fm.teal.alpha.feed.play',
4950 record
5051 };
5152 try {
5252- const resp = await this.client.agent.com.atproto.repo.createRecord(input);
5353- return {payload: input, response: resp.data};
5353+ const res = await this.client.client.post('com.atproto.repo.createRecord', {
5454+ input,
5555+ params: {}
5656+ });
5757+ return {payload: input, response: res.data};
5458 } catch (e) {
5559 throw new ScrobbleSubmitError(`Failed to create record for scrobble`, { cause: e, payload: input, response: 'response' in e ? e.response : undefined });
5660 }
5761 }
58625963 async updateStatusRecord(record: FmTealAlphaActorStatus.Main): Promise<ScrobbleActionResult> {
6060- const input: ComAtprotoRepoPutRecord.InputSchema = {
6161- repo: this.client.agent.sessionManager.did,
6464+ const input: ComAtprotoRepoPutRecord.$input = {
6565+ repo: this.client.userData.did,
6266 collection: "fm.teal.alpha.actor.status",
6367 rkey: "self",
6468 record
6569 };
6670 try {
6767- const resp = await this.client.agent.com.atproto.repo.putRecord(input);
6868- return {payload: input, response: resp.data};
7171+ const res = await this.client.client.post('com.atproto.repo.putRecord', {
7272+ input,
7373+ params: {}
7474+ });
7575+ return {payload: input, response: res.data};
6976 } catch (e) {
7077 throw new ScrobbleSubmitError(`Failed to update status record for scrobble`, { cause: e, payload: input, response: 'response' in e ? e.response : undefined });
7178 }
···8390 cursor = generateTID(dayjs.unix(to).toISOString());
8491 }
85928686- const resp = await this.client.listRecord("fm.teal.alpha.feed.play", {cursor, limit});
9393+ const resp = await this.client.client.get('com.atproto.repo.listRecords', {
9494+ params: {
9595+ repo: this.client.userData.did,
9696+ collection: "fm.teal.alpha.feed.play",
9797+ limit,
9898+ cursor
9999+ }
100100+ });
101101+102102+ if(!resp.ok) {
103103+ throw new UpstreamError('Fetching records from PDS failed', {cause: resp.data});
104104+ }
105105+87106 let fromTS: UnixTimestamp;
88107 if(resp.data.cursor !== undefined) {
89108 const { timestampUs } = decodeTid(resp.data.cursor);
+2-11
src/backend/scrobblers/TealfmScrobbler.ts
···1616import { TealClientConfig } from "../common/infrastructure/config/client/tealfm.js";
1717import { ATProtoAppApiClient } from "../common/vendor/atproto/ATProtoAppApiClient.js";
1818import { ATProtoOauthApiClient } from "../common/vendor/atproto/ATProtoOauthApiClient.js";
1919-import { AbstractATProtoApiClient } from "../common/vendor/atproto/AbstractATProtoApiClient.js";
2019import { playToRecord, TealApiClient } from "../common/vendor/teal/TealApiClient.js";
2120import { playToStatusRecord } from "../common/vendor/teal/TealApiClient.js";
2221import { nowPlayingExpirationDuration } from "../common/vendor/teal/TealApiClient.js";
···4847 this.scrobbleDelay = 1500;
4948 this.supportsNowPlaying = true;
5049 this.client = new TealApiClient(name, config.data, {...options, logger});
5151- // if(config.data.appPassword !== undefined) {
5252- // this.client = new BlueSkyAppApiClient(name, config.data, {...options, logger});
5353- // this.requiresAuthInteraction = false;
5454- // } else if(config.data.baseUri !== undefined) {
5555- // this.client = new BlueSkyOauthApiClient(name, config.data, {...options, logger});
5656- // } else {
5757- // throw new Error(`Must define either 'baseUri' or 'appPassword' in configuration!`);
5858- // }
5950 this.nowPlayingMaxThreshold = nowPlayingUpdateByPlayDuration;
6051 this.nowPlayingMinThreshold = (_) => 20;
6152 this.configDir = options.configDir;
···211202 // TODO use `since` to get CAR diff instead of entire repo
212203 // can use last import date from migrations table
213204 const filename = path.resolve(this.configDir, `${this.getSafeExternalId()}-${dayjs().unix()}.car`);
214214- await fsPromise.writeFile(filename, Buffer.from(((await this.client.client.getCAR()))));
205205+ await fsPromise.writeFile(filename, Buffer.from(((await this.client.client.getCAR(this.client.client.userData.did)))));
215206 return filename;
216207 }
217208···227218228219 await using repo = fromStream(stream);
229220230230- const did = this.client?.client?.agent?.sessionManager?.did;
221221+ const did = this.client.client.userData.did;
231222232223 let batch: RepositoryCreatePlayHistoricalOpts[] = [];
233224 let allGood = true;