A work-in-progress chat bot for Streamplace with chat overlay functionality
2
fork

Configure Feed

Select the types of activity you want to include in your feed.

Bot now shouts out follows and teleports

+91 -26
+5 -4
main.ts
··· 13 13 export const app = new App<State>(); 14 14 15 15 export const botInstances: Map<Did, StreamplaceBot> = new Map(); 16 + export const followerAtUris: Map<Did, string> = new Map(); 16 17 17 18 // Initialize for self 18 19 export const atprotoClient = new AtprotoClient(BOT_CREDENTIALS, BOT_SERVICE); ··· 32 33 100, // I should fetch all of them, but I also don't know if I can handle that many 33 34 ); 34 35 const filteredBacklinks = await filterByStreamplace(backlinks); 35 - for (const backlinkDid of filteredBacklinks) { 36 - //if(backlinkDid !== "did:plc:dvh3s2edmdijbcv3k45ze22l") continue; 37 - const streamplaceBot = new StreamplaceBot(backlinkDid); 36 + for (const backlink of filteredBacklinks) { 37 + const streamplaceBot = new StreamplaceBot(backlink.did); 38 38 streamplaceBot.init(); 39 - botInstances.set(backlinkDid, streamplaceBot); 39 + botInstances.set(backlink.did, streamplaceBot); 40 + followerAtUris.set(backlink.did, backlink.atUri); 40 41 } 41 42 } 42 43 streamplaceWS.start();
+12 -6
utils/constellationUtils.ts
··· 1 1 import { describeRepo, listRecords } from "./atcuteUtils.ts"; 2 2 import { didResolver } from "./didResolver.ts"; 3 + import { ResourceUri } from "@atcute/lexicons"; 3 4 4 5 // Microcosm backlinks 5 6 interface ConstellationResponse { ··· 42 43 } 43 44 44 45 export async function filterByStreamplace( 45 - backlinks: ConstellationResponse, 46 - ): Promise<Did[]> { 47 - const activeDids: Did[] = []; 46 + response: ConstellationResponse, 47 + ): Promise<{ did: Did; atUri: ResourceUri }[]> { 48 + const backlinks: { did: Did; atUri: ResourceUri }[] = []; 48 49 49 50 // Doing in batches probably necessary for larger following 50 - for (const follower of backlinks.linking_records) { 51 + for (const follower of response.linking_records) { 51 52 const profile = await didResolver.resolve(follower.did); 52 53 const repoDescription = await describeRepo( 53 54 follower.did, ··· 60 61 collection: "place.stream.chat.profile", 61 62 }); 62 63 if (livestreams.records?.length > 0) { 63 - activeDids.push(follower.did); 64 + backlinks.push({ 65 + did: follower.did, 66 + atUri: 67 + `at://${follower.did}/${follower.collection}/${follower.rkey}`, 68 + }); 64 69 } 65 70 } 66 - return activeDids; 71 + 72 + return backlinks; 67 73 }
+67 -4
utils/eventHandler.ts
··· 1 + import { AppBskyGraphFollow } from "@atcute/bluesky"; 2 + import RichtextBuilder from "@atcute/bluesky-richtext-builder"; 1 3 import { CommitEvent, JetstreamEvent } from "@atcute/jetstream"; 2 - import { botInstances } from "../main.ts"; 4 + import { is } from "@atcute/lexicons"; 5 + import { atprotoClient, botInstances, followerAtUris } from "../main.ts"; 6 + import { listRecords } from "./atcuteUtils.ts"; 3 7 import { didResolver } from "./didResolver.ts"; 4 - import { PlaceStreamChatMessage } from "./lexicons/index.ts"; 8 + import { 9 + PlaceStreamChatMessage, 10 + PlaceStreamLivestream, 11 + PlaceStreamLiveTeleport, 12 + } from "./lexicons/index.ts"; 13 + import StreamplaceBot from "./streamplaceBot.ts"; 5 14 import { CDN_URL } from "../env.ts"; 6 15 7 16 export class EventHandler { ··· 37 46 38 47 private async handleBskyFollow(event: CommitEvent): Promise<void> { 39 48 if (event.commit.operation === "create") { 40 - const record = event.commit.record; 49 + const record = event.commit.record as AppBskyGraphFollow.Main; 50 + // Case: user follows streamer 51 + if (botInstances.get(record.subject)) { 52 + const follower = await didResolver.resolve(event.did); 53 + const { text, facets } = new RichtextBuilder().addText( 54 + "Thank you for the following the streamer, ", 55 + ).addMention(follower.handle, event.did).addText("!"); 56 + botInstances.get(record.subject)!.sendMessage(text, facets); 57 + } 58 + // Case user follows bot 59 + if ( 60 + record.subject === atprotoClient.getDid() && 61 + !botInstances.get(event.did) 62 + ) { 63 + const newBot = new StreamplaceBot(event.did); 64 + await newBot.init(); 65 + botInstances.set(event.did, newBot); 66 + } 67 + } 68 + 69 + if (event.commit.operation === "delete") { 70 + // Case user unfollows bot 71 + if ( 72 + botInstances.get(event.did) 73 + ) { 74 + botInstances.get(event.did)!.setEnabled(false); 75 + followerAtUris.delete(event.did); 76 + botInstances.delete(event.did); 77 + } 41 78 } 42 79 } 43 80 ··· 80 117 } 81 118 82 119 private async handleTeleport(event: CommitEvent): Promise<void> { 120 + if (event.commit.operation === "create") { 121 + const record = event.commit.record as PlaceStreamLiveTeleport.Main; 122 + if (botInstances.get(record.streamer)) { 123 + const teleporter = await didResolver.resolve(event.did); 124 + const livestreams = await listRecords(teleporter.pdsEndpoint, { 125 + repo: event.did, 126 + collection: "place.stream.livestream", 127 + }); 128 + const richtextBuilder = new RichtextBuilder() 129 + .addText("Welcome in teleporters! ") 130 + .addMention(teleporter.handle, event.did) 131 + .addText(" last stream was titled "); 132 + if ( 133 + livestreams.records && 134 + is(PlaceStreamLivestream.mainSchema, livestreams.records[0].value) 135 + ) { 136 + const lastTitle = 137 + (livestreams.records[0].value as PlaceStreamLivestream.Main) 138 + .title; 139 + richtextBuilder.addText(`"${lastTitle}"!`); 140 + } 141 + 142 + const { text, facets } = richtextBuilder; 143 + botInstances.get(record.streamer)!.sendMessage(text, facets); 144 + } 145 + } 83 146 } 84 147 85 148 private async enrichMessage( ··· 124 187 mimeType?: string; 125 188 size?: number; 126 189 } 127 - 190 + 128 191 return { 129 192 service: "streamplace", 130 193 author: {
+7 -12
utils/streamplaceBot.ts
··· 1 1 import { AppBskyRichtextFacet } from "@atcute/bluesky"; 2 2 import { CommitEvent } from "@atcute/jetstream"; 3 + import { is } from "@atcute/lexicons"; 3 4 import { atprotoClient } from "../main.ts"; 4 5 import { AtprotoClient, listRecords } from "./atcuteUtils.ts"; 5 6 import { ··· 9 10 } from "./commandHandler.ts"; 10 11 import { didResolver } from "./didResolver.ts"; 11 12 import { 13 + OnlineTimtinkersBotShoutout, 12 14 PlaceStreamChatMessage, 13 15 PlaceStreamRichtextFacet, 14 16 } from "./lexicons/index.ts"; 15 17 import { buildRichtext } from "./richtextUtils.ts"; 16 18 17 - // Shoutout record from the repository 18 - interface ShoutoutRecord { 19 - user: Did; 20 - text: string; 21 - } 22 - 23 19 class StreamplaceBot { 24 20 private streamerDid: Did; 25 21 private commandPrefix: string; ··· 30 26 // Caching 31 27 private moderators: Did[] = []; 32 28 private moderatorsLoaded: boolean = false; 33 - private shoutouts: Map<Did, ShoutoutRecord> = new Map(); 29 + private shoutouts: Map<Did, OnlineTimtinkersBotShoutout.Main> = new Map(); 34 30 private hasBeenGreeted: Map<Did, boolean> = new Map(); 35 31 private shoutoutsLoaded: boolean = false; 36 32 ··· 116 112 // Cache all shoutouts 117 113 for (const record of shoutoutsData.records) { 118 114 const userDid = record.value.user as Did; 119 - this.shoutouts.set(userDid, { 120 - user: userDid, 121 - text: record.value.text as string, 122 - }); 115 + if (is(OnlineTimtinkersBotShoutout.mainSchema, record.value)) { 116 + this.shoutouts.set(userDid, record.value); 117 + } 123 118 124 119 // Also resolve and cache shoutoutees 125 120 await this.getUserProfile(userDid); ··· 231 226 return this.moderators; 232 227 } 233 228 234 - getShoutout(did: Did): ShoutoutRecord | undefined { 229 + getShoutout(did: Did): OnlineTimtinkersBotShoutout.Main | undefined { 235 230 return this.shoutouts.get(did); 236 231 } 237 232