diff --git a/config/tealfm.json.example b/config/tealfm.json.example
index d6ff53fa..352667d2 100644
--- a/config/tealfm.json.example
+++ b/config/tealfm.json.example
@@ -3,7 +3,7 @@
"name": "myTeal",
"configureAs": "client",
"data": {
- "identifier": "myemail@gmail.com",
+ "identifier": "alice.bsky.social",
"appPassword": "twog-phu7-4dhe-y4j3"
}
},
@@ -11,7 +11,7 @@
"name": "myTealSource",
"configureAs": "source",
"data": {
- "identifier": "myemail@gmail.com",
+ "identifier": "alice.bsky.social",
"appPassword": "twog-phu7-4dhe-y4j3"
}
}
diff --git a/docsite/docs/configuration/clients/tealfm.mdx b/docsite/docs/configuration/clients/tealfm.mdx
index 7f5287ac..063bbf14 100644
--- a/docsite/docs/configuration/clients/tealfm.mdx
+++ b/docsite/docs/configuration/clients/tealfm.mdx
@@ -40,8 +40,6 @@ So...
The default Bluesky PDS is **public**, meaning your scrobbles are also public (read-only). This isn't any different than using Last.fm or Listenbrainz, in terms of privacy.
-Multi-scrobbler lets you define a different **PDS** so you can store your scrobbles on **any** ATProto PDS/instance you have access to.
-
:::
:::note
@@ -66,10 +64,19 @@ To view your teal.fm scrobbles you can:
## Configuration
+### Handle
+
+The handle used with multi-scrobbler should be your **full** ATProto handle, including TLD.
+
+* For regular Bluesky account this will be like: `alice.bsky.social`
+* For Bluesky accounts that [use their website as their account](https://bsky.social/about/blog/4-28-2023-domain-handle-tutorial) this is your domain: `mydomain.com`
+* For non-Bluesky users, you probably already know your handle
+
+If you do not include a TLD then multi-scrobbler will automatically append `.bsky.social` to your handle value.
+
- | Environmental Variable | Required? | Default | Description |
- | :--------------------- | --------- | --------------------- | ---------------------------------------------- |
- | TEALFM_IDENTIFIER | Yes | | Email used to login to Bluesky/ATProto network |
- | TEALFM_APP_PW | Yes | | Bluesky/ATProto network App Password |
- | TEALFM_PDS | No | `https://bsky.social` | The URL for the PDS |
+ | Environmental Variable | Required? | Default | Description |
+ | :--------------------- | --------- | ------- | ----------------------------------------------------------------------------------- |
+ | TEALFM_IDENTIFIER | Yes | | Your **full** ATProto handle. For Bluesky account this is like `myUser.bsky.social` |
+ | TEALFM_APP_PW | Yes | | Bluesky/ATProto network App Password |
\ No newline at end of file
diff --git a/package-lock.json b/package-lock.json
index 4cdb7ff5..ea495abf 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -11,6 +11,7 @@
"license": "MIT",
"dependencies": {
"@astronautlabs/mdns": "^1.0.7",
+ "@atcute/identity-resolver-node": "^1.0.3",
"@atproto/api": "^0.18.0",
"@atproto/oauth-client-node": "^0.3.10",
"@awaitjs/express": "^0.6.3",
@@ -172,6 +173,65 @@
"resolved": "https://registry.npmjs.org/@astronautlabs/mdns/-/mdns-1.0.10.tgz",
"integrity": "sha512-9bVdQ15Tbzpor1z/bteaemC9NvRDZgMD4Cfbu1/r/6Zv6fCBXvIJJoJ6Dh/BMvTlAiT2Xewm36i0VWRnZ4yw6A=="
},
+ "node_modules/@atcute/identity": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/@atcute/identity/-/identity-1.1.1.tgz",
+ "integrity": "sha512-zax42n693VEhnC+5tndvO2KLDTMkHOz8UExwmklvJv7R9VujfEwiSWhcv6Jgwb3ellaG8wjiQ1lMOIjLLvwh0Q==",
+ "license": "0BSD",
+ "peer": true,
+ "dependencies": {
+ "@atcute/lexicons": "^1.2.2",
+ "@badrap/valita": "^0.4.6"
+ }
+ },
+ "node_modules/@atcute/identity-resolver": {
+ "version": "1.1.4",
+ "resolved": "https://registry.npmjs.org/@atcute/identity-resolver/-/identity-resolver-1.1.4.tgz",
+ "integrity": "sha512-/SVh8vf2cXFJenmBnGeYF2aY3WGQm3cJeew5NWTlkqoy3LvJ5wkvKq9PWu4Tv653VF40rPOp6LOdVr9Fa+q5rA==",
+ "license": "0BSD",
+ "peer": true,
+ "dependencies": {
+ "@atcute/lexicons": "^1.2.2",
+ "@atcute/util-fetch": "^1.0.3",
+ "@badrap/valita": "^0.4.6"
+ },
+ "peerDependencies": {
+ "@atcute/identity": "^1.0.0"
+ }
+ },
+ "node_modules/@atcute/identity-resolver-node": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/@atcute/identity-resolver-node/-/identity-resolver-node-1.0.3.tgz",
+ "integrity": "sha512-RPH5M4ZRayKRcGnJWUOPVhN5WSYURXXZxKzgVT9lj/WZCH6ij2Vg3P3Eva7GGs0SG1ytnX1XVBTMoIk8nF/SLQ==",
+ "license": "0BSD",
+ "dependencies": {
+ "@atcute/lexicons": "^1.2.2"
+ },
+ "peerDependencies": {
+ "@atcute/identity": "^1.0.0",
+ "@atcute/identity-resolver": "^1.0.0"
+ }
+ },
+ "node_modules/@atcute/lexicons": {
+ "version": "1.2.2",
+ "resolved": "https://registry.npmjs.org/@atcute/lexicons/-/lexicons-1.2.2.tgz",
+ "integrity": "sha512-bgEhJq5Z70/0TbK5sx+tAkrR8FsCODNiL2gUEvS5PuJfPxmFmRYNWaMGehxSPaXWpU2+Oa9ckceHiYbrItDTkA==",
+ "license": "0BSD",
+ "dependencies": {
+ "@standard-schema/spec": "^1.0.0",
+ "esm-env": "^1.2.2"
+ }
+ },
+ "node_modules/@atcute/util-fetch": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/@atcute/util-fetch/-/util-fetch-1.0.3.tgz",
+ "integrity": "sha512-f8zzTb/xlKIwv2OQ31DhShPUNCmIIleX6p7qIXwWwEUjX6x8skUtpdISSjnImq01LXpltGV5y8yhV4/Mlb7CRQ==",
+ "license": "0BSD",
+ "peer": true,
+ "dependencies": {
+ "@badrap/valita": "^0.4.6"
+ }
+ },
"node_modules/@atproto-labs/did-resolver": {
"version": "0.2.2",
"resolved": "https://registry.npmjs.org/@atproto-labs/did-resolver/-/did-resolver-0.2.2.tgz",
@@ -717,6 +777,16 @@
"node": ">=6.9.0"
}
},
+ "node_modules/@badrap/valita": {
+ "version": "0.4.6",
+ "resolved": "https://registry.npmjs.org/@badrap/valita/-/valita-0.4.6.tgz",
+ "integrity": "sha512-4kdqcjyxo/8RQ8ayjms47HCWZIF5981oE5nIenbfThKDxWXtEHKipAOWlflpPJzZx9y/JWYQkp18Awr7VuepFg==",
+ "license": "MIT",
+ "peer": true,
+ "engines": {
+ "node": ">= 18"
+ }
+ },
"node_modules/@bufbuild/protobuf": {
"version": "2.2.1",
"resolved": "https://registry.npmjs.org/@bufbuild/protobuf/-/protobuf-2.2.1.tgz",
@@ -2520,6 +2590,12 @@
"url": "https://github.com/sindresorhus/is?sponsor=1"
}
},
+ "node_modules/@standard-schema/spec": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.0.0.tgz",
+ "integrity": "sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA==",
+ "license": "MIT"
+ },
"node_modules/@stdlib/error-tools-fmtprodmsg": {
"version": "0.2.2",
"resolved": "https://registry.npmjs.org/@stdlib/error-tools-fmtprodmsg/-/error-tools-fmtprodmsg-0.2.2.tgz",
@@ -5676,6 +5752,12 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
+ "node_modules/esm-env": {
+ "version": "1.2.2",
+ "resolved": "https://registry.npmjs.org/esm-env/-/esm-env-1.2.2.tgz",
+ "integrity": "sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA==",
+ "license": "MIT"
+ },
"node_modules/espree": {
"version": "9.6.1",
"resolved": "https://registry.npmjs.org/espree/-/espree-9.6.1.tgz",
diff --git a/package.json b/package.json
index 4c174333..17168e6e 100644
--- a/package.json
+++ b/package.json
@@ -43,6 +43,7 @@
"homepage": ".",
"dependencies": {
"@astronautlabs/mdns": "^1.0.7",
+ "@atcute/identity-resolver-node": "^1.0.3",
"@atproto/api": "^0.18.0",
"@atproto/oauth-client-node": "^0.3.10",
"@awaitjs/express": "^0.6.3",
diff --git a/src/backend/common/infrastructure/config/client/tealfm.ts b/src/backend/common/infrastructure/config/client/tealfm.ts
index 1ffea0d6..1ee12498 100644
--- a/src/backend/common/infrastructure/config/client/tealfm.ts
+++ b/src/backend/common/infrastructure/config/client/tealfm.ts
@@ -41,12 +41,6 @@ export interface TealClientConfig extends CommonClientConfig {
}
export interface TealOptions {
- /** The [PDS (Personal Data Server)](https://github.com/bluesky-social/pds) to use
- *
- * @default "https://bsky.social"
- * @examples ["https://bsky.social"]
- */
- pds?: string
}
diff --git a/src/backend/common/vendor/bluesky/AbstractBlueSkyApiClient.ts b/src/backend/common/vendor/bluesky/AbstractBlueSkyApiClient.ts
index 990198b7..70257313 100644
--- a/src/backend/common/vendor/bluesky/AbstractBlueSkyApiClient.ts
+++ b/src/backend/common/vendor/bluesky/AbstractBlueSkyApiClient.ts
@@ -25,7 +25,7 @@ export abstract class AbstractBlueSkyApiClient extends AbstractApiClient {
this.cache = getRoot().items.cache();
}
- abstract initClient(): void;
+ abstract initClient(): Promise;
abstract restoreSession(): Promise;
diff --git a/src/backend/common/vendor/bluesky/BlueSkyAppApiClient.ts b/src/backend/common/vendor/bluesky/BlueSkyAppApiClient.ts
index 3f582a0e..412288c3 100644
--- a/src/backend/common/vendor/bluesky/BlueSkyAppApiClient.ts
+++ b/src/backend/common/vendor/bluesky/BlueSkyAppApiClient.ts
@@ -5,40 +5,100 @@ import { AbstractBlueSkyApiClient } from "./AbstractBlueSkyApiClient.js";
import { isPortReachableConnect, normalizeWebAddress } from "../../../utils/NetworkUtils.js";
import { URLData } from "../../../../core/Atomic.js";
import { isNodeNetworkException } from "../../errors/NodeErrors.js";
+import {
+ CompositeDidDocumentResolver,
+ CompositeHandleResolver,
+ DohJsonHandleResolver,
+ PlcDidDocumentResolver,
+ WebDidDocumentResolver,
+ WellKnownHandleResolver,
+} from "@atcute/identity-resolver";
+import { AtprotoDid, DidDocument } from "@atproto/oauth-client-node";
+interface HandleData {
+ did: string
+ pds: string
+}
export class BlueSkyAppApiClient extends AbstractBlueSkyApiClient {
declare config: TealClientData;
-
- pds: URLData
appSession?: CredentialSession;
appPwAuth: boolean
- constructor(name: any, config: TealClientData & {pds?: string}, options: AbstractApiOptions) {
+ constructor(name: any, config: TealClientData, options: AbstractApiOptions) {
super(name, config, options);
-
- this.pds = normalizeWebAddress(config.pds ?? 'https://bsky.social');
- this.logger.verbose(`Using App Password auth for session with PDS ${this.pds.url}`);
+ this.logger.verbose(`Using App Password auth for session`);
}
- protected initClientApp() {
- this.appSession = new CredentialSession(this.pds.url, undefined, (evt: AtpSessionEvent, sess?: AtpSessionData) => {
- this.cache.cacheAuth.set(`appPwSession-${this.name}`, sess);
+ async initClient(): Promise {
+ const hd = await this.getATProtoIdentifier();
+ 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);
}
- initClient() {
- this.appSession = new CredentialSession(this.pds.url, undefined, (evt: AtpSessionEvent, sess?: AtpSessionData) => {
- this.cache.cacheAuth.set(`appPwSession-${this.name}`, sess);
+ protected async getATProtoIdentifier(): Promise {
+
+ let hd: HandleData;
+ hd = await this.cache.cacheAuth.get(`${this.name}-handleData`);
+ if (hd !== undefined) {
+ this.logger.debug('Found cached handle data');
+ return hd;
+ } else {
+ this.logger.debug('Handle data not cached, attempting to resolve...');
+ }
+
+ const handleResolver = new CompositeHandleResolver({
+ strategy: "race",
+ methods: {
+ dns: new DohJsonHandleResolver({
+ dohUrl: "https://mozilla.cloudflare-dns.com/dns-query",
+ }),
+ http: new WellKnownHandleResolver(),
+ },
});
- this.agent = new Agent(this.appSession);
+
+ let did: AtprotoDid;
+ try {
+ did = await handleResolver.resolve(this.config.identifier as `${string}.${string}`);
+ this.logger.debug(`Resolved ${did}`);
+ } catch (e) {
+ throw new Error('Unable to resolve handle', { cause: e });
+ }
+
+ const docResolver = new CompositeDidDocumentResolver({
+ methods: {
+ plc: new PlcDidDocumentResolver(),
+ web: new WebDidDocumentResolver(),
+ },
+ });
+
+ let doc: Awaited>;
+ try {
+ doc = await docResolver.resolve(did);
+ } catch (e) {
+ throw new Error('Unable to resolve did document', { cause: e });
+ }
+ if (doc.service === undefined || doc.service.length === 0) {
+ throw new Error('did document did not return a service');
+ }
+
+ if (typeof doc.service[0].serviceEndpoint !== 'string') {
+ throw new Error(`Do not know how to handle this serviceEndpoint data structure!\n${JSON.stringify(doc.service[0].serviceEndpoint)}`);
+ }
+ hd = { did, pds: doc.service[0].serviceEndpoint };
+
+ this.cache.cacheAuth.set(`${this.name}-handleData`, hd, '1d');
+ return hd;
}
restoreSession = async (): Promise => {
- const savedSession = await this.cache.cacheAuth.get(`appPwSession-${this.name}`);
+ const hd = await this.getATProtoIdentifier();
+ const savedSession = await this.cache.cacheAuth.get(`appPwSession-${this.name}-${hd.did}`);
if (savedSession !== undefined) {
try {
this.logger.debug('Found existing session, trying to resume...');
@@ -73,8 +133,17 @@ export class BlueSkyAppApiClient extends AbstractBlueSkyApiClient {
}
async checkPds(): Promise {
+ let hd: HandleData;
+ try {
+ hd = await this.getATProtoIdentifier();
+ } catch(e) {
+ throw new Error('Unable to get handle data', {cause: e});
+ }
+
+ const normal = normalizeWebAddress(hd.pds);
+
try {
- await isPortReachableConnect(this.pds.port, {host: this.pds.url.hostname});
+ await isPortReachableConnect(normal.port, {host: normal.url.hostname});
return true;
} catch (e) {
if(isNodeNetworkException(e)) {
diff --git a/src/backend/common/vendor/bluesky/BlueSkyOauthApiClient.ts b/src/backend/common/vendor/bluesky/BlueSkyOauthApiClient.ts
index 81b8415d..6d31dd44 100644
--- a/src/backend/common/vendor/bluesky/BlueSkyOauthApiClient.ts
+++ b/src/backend/common/vendor/bluesky/BlueSkyOauthApiClient.ts
@@ -24,7 +24,7 @@ export class BlueSkyOauthApiClient extends AbstractBlueSkyApiClient {
this.logger.verbose('Will use oauth for session');
}
- initClient = () => {
+ initClient = async () => {
const sessionStore: NodeSavedSessionStore = {
set: (k: string, state) => this.cache.cacheAuth.set(`session-${this.name}-${k}`, state).then(() => null),
get: (k: string) => this.cache.cacheAuth.get(`session-${this.name}-${k}`),
diff --git a/src/backend/scrobblers/TealfmScrobbler.ts b/src/backend/scrobblers/TealfmScrobbler.ts
index 9320ae12..81ced851 100644
--- a/src/backend/scrobblers/TealfmScrobbler.ts
+++ b/src/backend/scrobblers/TealfmScrobbler.ts
@@ -29,10 +29,10 @@ export default class TealScrobbler extends AbstractScrobbleClient {
this.scrobbleDelay = 1500;
this.supportsNowPlaying = false;
if(config.data.appPassword !== undefined) {
- this.client = new BlueSkyAppApiClient(name, {...config.data, pds: config.options?.pds}, {...options, logger});
+ 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, pds: config.options?.pds}, {...options, logger});
+ this.client = new BlueSkyOauthApiClient(name, config.data, {...options, logger});
} else {
throw new Error(`Must define either 'baseUri' or 'appPassword' in configuration!`);
}
@@ -54,7 +54,7 @@ export default class TealScrobbler extends AbstractScrobbleClient {
if (identifier === undefined) {
throw new Error('Must provide an identifier');
}
- this.client.initClient();
+ await this.client.initClient();
return true;
}
diff --git a/src/backend/sources/TealfmSource.ts b/src/backend/sources/TealfmSource.ts
index 6bafa837..3d6cd742 100644
--- a/src/backend/sources/TealfmSource.ts
+++ b/src/backend/sources/TealfmSource.ts
@@ -30,10 +30,10 @@ export default class TealfmSource extends MemorySource {
this.canPoll = true;
this.canBacklog = true;
if(config.data.appPassword !== undefined) {
- this.client = new BlueSkyAppApiClient(name, {...config.data, pds: config.options?.pds}, {...internal, logger: internal.logger});
+ this.client = new BlueSkyAppApiClient(name, config.data, {...internal, logger: internal.logger});
this.requiresAuthInteraction = false;
} else if(config.data.baseUri !== undefined) {
- this.client = new BlueSkyOauthApiClient(name, {...config.data, pds: config.options?.pds}, {...internal, logger: internal.logger});
+ this.client = new BlueSkyOauthApiClient(name, config.data, {...internal, logger: internal.logger});
} else {
throw new Error(`Must define either 'baseUri' or 'appPassword' in configuration!`);
}
@@ -53,7 +53,7 @@ export default class TealfmSource extends MemorySource {
if (identifier === undefined) {
throw new Error('Must provide an identifier');
}
- this.client.initClient();
+ await this.client.initClient();
return true;
}