···11import { App, staticFiles } from "fresh";
22import { type State } from "./utils.ts";
33+import { streamplaceWS } from "./utils/websocket.ts";
44+import StreamplaceBot from "./utils/streamplaceBot.ts";
55+import { ATPROTO_PASSWORD, ATPROTO_USERNAME, PDS_HOST_URL } from "./env.ts";
3647export const app = new App<State>();
5899+export const streamplaceBot = new StreamplaceBot(
1010+ "did:plc:o6xucog6fghiyrvp7pyqxcs3", //this is me, make more flexible later
1111+ {
1212+ username: ATPROTO_USERNAME!,
1313+ password: ATPROTO_PASSWORD!,
1414+ pdsHostUrl: PDS_HOST_URL!,
1515+ },
1616+);
1717+streamplaceBot.init();
1818+streamplaceWS.start();
619app.use(staticFiles());
720821// Include file-system based routes here
+123-57
utils/atcuteUtils.ts
···11-import { Client, CredentialManager, ok } from "@atcute/client";
11+import type {} from "@atcute/atproto";
22+import {
33+ Client,
44+ CredentialManager,
55+ ok,
66+ simpleFetchHandler,
77+} from "@atcute/client";
28import type {} from "@atcute/atproto";
39import {
410 CompositeHandleResolver,
···713} from "@atcute/identity-resolver";
814import { Handle } from "@atcute/lexicons";
915import { isHandle } from "@atcute/lexicons/syntax";
1010-import { ATPROTO_PASSWORD, ATPROTO_USERNAME, PDS_HOST_URL } from "../env.ts";
11161212-// Store credential manager singleton to avoid multiple logins
1313-let credentialManager: CredentialManager | null = null;
1414-let rpcClient: Client | null = null;
1717+// Configuration for initializing a Streamplace client
1818+export interface StreamplaceClientConfig {
1919+ username: string;
2020+ password: string;
2121+ pdsHostUrl: string;
2222+}
15231616-export async function initStreamplaceClient(): Promise<void> {
1717- if (credentialManager !== null) return;
2424+// Wrapper class for managing a single bot's credentials and RPC client
2525+export class StreamplaceClient {
2626+ private credentialManager: CredentialManager;
2727+ private rpcClient: Client;
2828+ private config: StreamplaceClientConfig;
2929+ private initialized: boolean = false;
18301919- credentialManager = new CredentialManager({
2020- service: PDS_HOST_URL!,
2121- });
2222- rpcClient = new Client({ handler: credentialManager });
3131+ constructor(config: StreamplaceClientConfig) {
3232+ this.config = config;
3333+ this.credentialManager = new CredentialManager({
3434+ service: config.pdsHostUrl,
3535+ });
3636+ this.rpcClient = new Client({ handler: this.credentialManager });
3737+ }
23382424- await credentialManager.login({
2525- identifier: ATPROTO_USERNAME!,
2626- password: ATPROTO_PASSWORD!,
2727- });
3939+ // Initialize the client by logging in
4040+ async init(): Promise<void> {
4141+ if (this.initialized) return;
28422929- console.log("Streamplace client initialized successfully");
3030-}
4343+ await this.credentialManager.login({
4444+ identifier: this.config.username,
4545+ password: this.config.password,
4646+ });
31473232-export async function createStreamplaceMessage(
3333- text: string,
3434- streamerDid: string,
3535- replyTo?: {
3636- rootUri: string;
3737- rootCid: string;
3838- parentUri: string;
3939- parentCid: string;
4040- },
4141-): Promise<void> {
4242- // Make sure client is initialized
4343- if (!credentialManager || !rpcClient) {
4444- await initStreamplaceClient();
4848+ this.initialized = true;
4949+ console.log(
5050+ `Streamplace client initialized for ${this.config.username}`,
5151+ );
4552 }
46534747- // Create the message record according to the lexicon
4848- const record = {
4949- text: text, // Plain text - stream.place will handle richtext formatting
5050- streamer: streamerDid,
5151- reply: {},
5252- createdAt: new Date().toISOString(),
5353- };
5454+ // Ensure the client is initialized before use
5555+ private async ensureInitialized(): Promise<void> {
5656+ if (!this.initialized) {
5757+ await this.init();
5858+ }
5959+ }
54605555- // Add reply reference if provided
5656- if (replyTo) {
5757- record.reply = {
5858- root: {
5959- uri: replyTo.rootUri,
6060- cid: replyTo.rootCid,
6161- },
6262- parent: {
6363- uri: replyTo.parentUri,
6464- cid: replyTo.parentCid,
6565- },
6161+ // Create a chat message on Streamplace
6262+ async createMessage(
6363+ text: string,
6464+ streamerDid: string,
6565+ facets?: any,
6666+ replyTo?: {
6767+ rootUri: string;
6868+ rootCid: string;
6969+ parentUri: string;
7070+ parentCid: string;
7171+ },
7272+ ): Promise<any> {
7373+ await this.ensureInitialized();
7474+7575+ // Create the message record according to the lexicon
7676+ const record = {
7777+ text: text,
7878+ facets: facets,
7979+ streamer: streamerDid,
8080+ reply: {},
8181+ createdAt: new Date().toISOString(),
6682 };
8383+8484+ // Add reply reference if provided
8585+ if (replyTo) {
8686+ record.reply = {
8787+ root: {
8888+ uri: replyTo.rootUri,
8989+ cid: replyTo.rootCid,
9090+ },
9191+ parent: {
9292+ uri: replyTo.parentUri,
9393+ cid: replyTo.parentCid,
9494+ },
9595+ };
9696+ }
9797+9898+ const result = await ok(
9999+ this.rpcClient.post("com.atproto.repo.createRecord", {
100100+ input: {
101101+ repo: this.credentialManager.session!.did,
102102+ collection: "place.stream.chat.message",
103103+ record: record,
104104+ },
105105+ }),
106106+ );
107107+108108+ return result;
67109 }
681106969- await ok(rpcClient!.post("com.atproto.repo.createRecord", {
7070- input: {
7171- repo: credentialManager!.session!.did,
7272- collection: "place.stream.chat.message",
7373- record: record,
7474- },
7575- }));
111111+ // Get the current session DID
112112+ getDid(): string | undefined {
113113+ return this.credentialManager.session?.did;
114114+ }
115115+116116+ // Get shoutouts from a specific DID's repository
117117+ async getShoutouts(did: Did, pdsFallback?: string): Promise<any> {
118118+ await this.ensureInitialized();
119119+120120+ const rpc = new Client({
121121+ // TODO: get dynamic PDS - for now using fallback or configured PDS
122122+ handler: simpleFetchHandler({
123123+ service: pdsFallback || this.config.pdsHostUrl,
124124+ }),
125125+ });
126126+127127+ const shoutouts = await ok(
128128+ rpc.get("com.atproto.repo.listRecords", {
129129+ params: {
130130+ repo: did,
131131+ collection: "online.timtinkers.bot.shoutout",
132132+ limit: 100,
133133+ // TODO: cursor for pagination
134134+ reverse: false,
135135+ },
136136+ }),
137137+ );
138138+139139+ return shoutouts;
140140+ }
76141}
771427878-// Handle resolver
143143+// Shared handle resolver instance
79144const handleResolver = new CompositeHandleResolver({
80145 strategy: "dns-first",
81146 methods: {
···86151 },
87152});
881538989-export async function resolveHandle(handle: Handle) {
154154+// Resolve a handle to a DID
155155+export async function resolveHandle(handle: Handle): Promise<Did> {
90156 if (!isHandle(handle)) {
91157 throw new Error("Not a valid handle");
92158 }
+211-46
utils/streamplaceBot.ts
···11import RichtextBuilder from "@atcute/bluesky-richtext-builder";
22-import { createStreamplaceMessage, getShoutouts } from "./atcuteUtils.ts";
22+import { StreamplaceClient, StreamplaceClientConfig } from "./atcuteUtils.ts";
33import { didResolver } from "./didResolver.ts";
44-import { ATPROTO_USERNAME } from "../env.ts";
5465export interface CommandHandler {
76 (message: JetstreamMessage, args: string[]): Promise<void> | void;
87}
9899+// Cached information about a chatter
1010+export interface Chatter {
1111+ did: Did;
1212+ handle: Handle;
1313+ hasShoutout: boolean;
1414+ hasBeenGreeted: boolean;
1515+}
1616+1717+// Shoutout record from the repository
1818+interface ShoutoutRecord {
1919+ user: Did;
2020+ text: string;
2121+ facets?: any;
2222+}
2323+1024class StreamplaceBot {
1111- private streamerDid: string;
2525+ private streamerDid: Did;
1226 private commandPrefix: string;
1327 private commands: Map<string, CommandHandler>;
1428 private enabled: boolean;
1515- private botDid: string | null = null;
2929+ private client: StreamplaceClient;
3030+3131+ // Caching
3232+ private chatters: Map<Did, Chatter> = new Map();
3333+ private shoutouts: Map<Did, ShoutoutRecord> = new Map();
3434+ private shoutoutsLoaded: boolean = false;
16351736 /**
1837 * Create a new StreamplaceBot
1938 * @param streamerDid The DID of the streamer whose chat to respond in
3939+ * @param clientConfig Configuration for the bot's AT Protocol client
2040 * @param commandPrefix The prefix that triggers bot commands (default: "!")
2141 */
2222- constructor(streamerDid: string, commandPrefix = "!") {
4242+ constructor(
4343+ streamerDid: Did,
4444+ clientConfig: StreamplaceClientConfig,
4545+ commandPrefix = "!",
4646+ ) {
2347 this.streamerDid = streamerDid;
2448 this.commandPrefix = commandPrefix;
2549 this.commands = new Map();
2650 this.enabled = true;
2727- this.botDid = ATPROTO_USERNAME!;
5151+ this.client = new StreamplaceClient(clientConfig);
5252+ }
5353+5454+ // Initialize the bot - must be called before use
5555+ async init(): Promise<void> {
5656+ // Initialize the AT Protocol client
5757+ await this.client.init();
5858+5959+ // Load shoutouts into cache
6060+ await this.loadShoutouts();
28612962 // Register default commands
3030- this.registerDefaultCommands();
6363+ await this.registerDefaultCommands();
6464+6565+ console.log(
6666+ `StreamplaceBot initialized for streamer: ${this.streamerDid}`,
6767+ );
6868+ }
6969+7070+ // Load all shoutouts for the streamer into the cache
7171+ private async loadShoutouts(): Promise<void> {
7272+ if (this.shoutoutsLoaded) return;
7373+7474+ try {
7575+ const shoutoutsData = await this.client.getShoutouts(
7676+ this.streamerDid,
7777+ "https://pds.timtinkers.online",
7878+ );
7979+8080+ // Cache all shoutouts
8181+ for (const record of shoutoutsData.records) {
8282+ const userDid = record.value.user as Did;
8383+ this.shoutouts.set(userDid, {
8484+ user: userDid,
8585+ text: record.value.text,
8686+ facets: record.value.facets,
8787+ });
8888+8989+ // Also resolve and cache the handle for users with shoutouts
9090+ await this.getOrCacheChatter(userDid, true);
9191+ }
9292+9393+ this.shoutoutsLoaded = true;
9494+ console.log(`Loaded ${this.shoutouts.size} shoutouts into cache`);
9595+ } catch (error) {
9696+ console.error("Error loading shoutouts:", error);
9797+ }
9898+ }
9999+100100+ // Get a chatter from cache or fetch and cache their information
101101+ private async getOrCacheChatter(
102102+ did: Did,
103103+ hasShoutout: boolean = false,
104104+ ): Promise<Chatter> {
105105+ // Check if already cached
106106+ let chatter = this.chatters.get(did);
107107+108108+ if (!chatter) {
109109+ // Resolve handle and create new chatter entry
110110+ try {
111111+ const resolved = await didResolver.resolve(did);
112112+ chatter = {
113113+ did: did,
114114+ handle: resolved.handle as Handle,
115115+ hasShoutout: hasShoutout || this.shoutouts.has(did),
116116+ hasBeenGreeted: false,
117117+ };
118118+ this.chatters.set(did, chatter);
119119+ console.log(`Cached new chatter: ${chatter.handle}`);
120120+ } catch (error) {
121121+ console.error(`Error resolving handle for DID ${did}:`, error);
122122+ // Create minimal chatter entry even if resolution fails
123123+ chatter = {
124124+ did: did,
125125+ handle: did as Handle, // Fallback to DID
126126+ hasShoutout: hasShoutout || this.shoutouts.has(did),
127127+ hasBeenGreeted: false,
128128+ };
129129+ this.chatters.set(did, chatter);
130130+ }
131131+ } else if (hasShoutout && !chatter.hasShoutout) {
132132+ // Update shoutout status if needed
133133+ chatter.hasShoutout = true;
134134+ }
135135+136136+ return chatter;
31137 }
3213833139 /**
···40146 const record = message.commit.record!;
41147 const text = record.text.trim();
42148149149+ // Get or cache chatter information
150150+ const chatter = await this.getOrCacheChatter(message.did);
151151+152152+ // Auto-greet first-time chatters with shoutout if they have one
153153+ if (!chatter.hasBeenGreeted) {
154154+ chatter.hasBeenGreeted = true;
155155+156156+ if (chatter.hasShoutout) {
157157+ const shoutout = this.shoutouts.get(message.did);
158158+ if (shoutout) {
159159+ await this.sendMessage(shoutout.text, shoutout.facets);
160160+ }
161161+ }
162162+ }
163163+43164 // Check if message starts with command prefix
44165 if (!text.startsWith(this.commandPrefix)) return;
45166···52173 const handler = this.commands.get(commandName);
53174 if (handler) {
54175 console.log(
5555- `Executing command: ${commandName} from user: ${message.did}`,
176176+ `Executing command: ${commandName} from user: ${chatter.handle}`,
56177 );
57178 try {
58179 await handler(message, args);
···89210 /**
90211 * Send a message to the chat
91212 * @param text Message text
213213+ * @param facets Optional facets for mentions, links, etc.
92214 */
93215 async sendMessage(text: string, facets?: any): Promise<void> {
94216 try {
9595- const result = await createStreamplaceMessage(
217217+ const result = await this.client.createMessage(
96218 text,
97219 this.streamerDid,
98220 facets,
99221 );
100100- // Store bot's DID if not already known (from the first message)
101101- if (!this.botDid && result?.uri) {
102102- const didMatch = result.uri.match(/at:\/\/(did:[^\/]+)/);
103103- if (didMatch) {
104104- this.botDid = didMatch[1];
105105- console.log(`Bot DID identified as: ${this.botDid}`);
106106- }
107107- }
108222 console.log(`Bot sent message: ${text}`);
109223 } catch (error) {
110224 console.error("Error sending bot message:", error);
111225 }
112226 }
113227114114- // Get the bot's DID
115115- getBotDid(): string | null {
116116- return this.botDid;
228228+ /**
229229+ * Get the bot's DID
230230+ */
231231+ getBotDid(): string | undefined {
232232+ return this.client.getDid();
117233 }
118234119119- // Get the streamer's DID
120120- getStreamerDid(): string | null {
235235+ /**
236236+ * Get the streamer's DID
237237+ */
238238+ getStreamerDid(): string {
121239 return this.streamerDid;
122240 }
123241124124- // Register the default set of commands
125125- private async registerDefaultCommands(): void {
126126- // Get shoutouts
127127- const shoutouts = await getShoutouts(this.streamerDid) as any;
242242+ /**
243243+ * Get cached chatter information
244244+ */
245245+ getChatter(did: Did): Chatter | undefined {
246246+ return this.chatters.get(did);
247247+ }
128248249249+ /**
250250+ * Get all cached chatters
251251+ */
252252+ getAllChatters(): Map<Did, Chatter> {
253253+ return new Map(this.chatters);
254254+ }
255255+256256+ /**
257257+ * Clear the chatter cache
258258+ */
259259+ clearChatterCache(): void {
260260+ this.chatters.clear();
261261+ console.log("Chatter cache cleared");
262262+ }
263263+264264+ /**
265265+ * Reload shoutouts from the repository
266266+ */
267267+ async reloadShoutouts(): Promise<void> {
268268+ this.shoutoutsLoaded = false;
269269+ this.shoutouts.clear();
270270+ await this.loadShoutouts();
271271+ }
272272+273273+ // Register the default set of commands
274274+ private registerDefaultCommands(): void {
129275 // Help command
130276 this.registerCommand("commands", async (_message, _args) => {
131277 const commandsList = Array.from(this.commands.keys())
···135281 await this.sendMessage(`Available commands: ${commandsList}`);
136282 });
137283138138- this.registerCommand("shoutout", async (message, args) => {
139139- // TO-DO: caching
140140- const senderHandle = await didResolver.resolve(message.did);
141141- const shoutouteeDid = message.commit.record!.facets![0].features[0]!
142142- .did as Did;
284284+ // Shoutout command
285285+ this.registerCommand("shoutout", async (message, _args) => {
286286+ if (!message.commit.record?.facets?.[0]?.features?.[0]?.did) {
287287+ await this.sendMessage(
288288+ "Please mention a user to give them a shoutout!",
289289+ );
290290+ return;
291291+ }
143292144144- const foundRecord = shoutouts.records.find((record) =>
145145- record.value.user === shoutouteeDid
293293+ const senderChatter = await this.getOrCacheChatter(message.did);
294294+ const shoutouteeDid = message.commit.record.facets[0].features[0]
295295+ .did as Did;
296296+ const shoutouteeChatter = await this.getOrCacheChatter(
297297+ shoutouteeDid,
146298 );
147147- if (foundRecord) {
148148- await this.sendMessage(foundRecord.value.text, foundRecord.value.facets);
299299+300300+ // Check if there's a custom shoutout
301301+ const customShoutout = this.shoutouts.get(shoutouteeDid);
302302+303303+ if (customShoutout) {
304304+ await this.sendMessage(
305305+ customShoutout.text,
306306+ customShoutout.facets,
307307+ );
149308 } else {
309309+ // Generic shoutout
150310 const { text, facets } = new RichtextBuilder()
151151- .addMention(`@${senderHandle.handle}`, message.did)
311311+ .addMention(`@${senderChatter.handle}`, message.did)
152312 .addText(" gives ")
153153- .addMention(`@${args[0].replace(/^@+/, "")}`, shoutouteeDid)
313313+ .addMention(`@${shoutouteeChatter.handle}`, shoutouteeDid)
154314 .addText(" a shoutout!");
155315156316 await this.sendMessage(text, facets);
···158318 });
159319160320 // Hug command
161161- this.registerCommand("hug", async (message, args) => {
162162- // TO-DO: caching
163163- const senderHandle = await didResolver.resolve(message.did);
164164- const huggeeDid = message.commit.record!.facets![0].features[0]!
321321+ this.registerCommand("hug", async (message, _args) => {
322322+ if (!message.commit.record?.facets?.[0]?.features?.[0]?.did) {
323323+ await this.sendMessage(
324324+ "Please mention a user to give them a hug!",
325325+ );
326326+ return;
327327+ }
328328+329329+ const senderChatter = await this.getOrCacheChatter(message.did);
330330+ const huggeeDid = message.commit.record.facets[0].features[0]
165331 .did as Did;
332332+ const huggeeChatter = await this.getOrCacheChatter(huggeeDid);
166333167334 const { text, facets } = new RichtextBuilder()
168168- .addMention(`@${senderHandle.handle}`, message.did)
335335+ .addMention(`@${senderChatter.handle}`, message.did)
169336 .addText(" gives ")
170170- .addMention(`@${args[0].replace(/^@+/, "")}`, huggeeDid)
337337+ .addMention(`@${huggeeChatter.handle}`, huggeeDid)
171338 .addText(" a big hug!");
172339173340 await this.sendMessage(text, facets);
···175342 }
176343}
177344178178-export const streamplaceBot = new StreamplaceBot(
179179- "did:plc:o6xucog6fghiyrvp7pyqxcs3",
180180-);
345345+export default StreamplaceBot;
+11-2
utils/websocket.ts
···11import { JETSTREAM_URL } from "../env.ts";
22import { didResolver } from "./didResolver.ts";
33+import { streamplaceBot } from "../main.ts";
3445// Client subscription message
56interface SubscriptionMessage {
···89}
9101011// WebSocket service class
1111-export class StreamplaceWebSocketService {
1212+class StreamplaceWebSocketService {
1213 private jetstreamWs: WebSocket | null = null;
1314 private clients = new Map<WebSocket, Set<string>>(); // client -> subscribed streamers
1415 private streamerClients = new Map<string, Set<WebSocket>>(); // streamer -> clients
···5253 if (
5354 jetstreamMessage.kind === "commit" &&
5455 jetstreamMessage.commit?.operation === "create" &&
5555- jetstreamMessage.commit.record
5656+ jetstreamMessage.commit.record &&
5757+ // TODO: make flexible later
5858+ jetstreamMessage.commit.record.streamer ===
5959+ "did:plc:o6xucog6fghiyrvp7pyqxcs3"
5660 ) {
5761 this.processJetstreamMessage(jetstreamMessage);
5862 }
···138142 }
139143140144 private async processJetstreamMessage(jetstreamMessage: JetstreamMessage) {
145145+ // Let bot handle it
146146+ streamplaceBot.processMessage(jetstreamMessage);
147147+141148 // Extract the record data
142149 const record = jetstreamMessage.commit.record!;
143150 const enrichedMessage = await this.enrichMessage(
···187194 };
188195 }
189196}
197197+198198+export const streamplaceWS = new StreamplaceWebSocketService();