Live video on the AT Protocol
1import { ChatMessageViewHydrated } from "streamplace";
2
3export enum SystemMessageType {
4 stream_start = "stream_start",
5 stream_end = "stream_end",
6 notification = "notification",
7 command_error = "command_error",
8}
9
10export interface SystemMessageMetadata {
11 username?: string;
12 action?: string;
13 count?: number;
14 duration?: string;
15 reason?: string;
16 streamerName?: string;
17}
18
19/**
20 * Creates a system message with the proper structure
21 * @param type The type of system message
22 * @param text The message text
23 * @param metadata Optional metadata for the message
24 * @returns A properly formatted ChatMessageViewHydrated object
25 */
26let systemMessageCounter = 0;
27
28export const createSystemMessage = (
29 type: SystemMessageType,
30 text: string,
31 metadata?: SystemMessageMetadata,
32 date: Date = new Date(),
33): ChatMessageViewHydrated => {
34 const now = date;
35 const uniqueId = `${now.getTime()}-${systemMessageCounter++}`;
36
37 return {
38 uri: `at://did:sys:system/place.stream.chat.message/${uniqueId}`,
39 cid: `system-${uniqueId}`,
40 author: {
41 did: "did:sys:system",
42 handle: type, // Use handle to specify the type of system message
43 },
44 record: {
45 text,
46 createdAt: now.toISOString(),
47 streamer: "system",
48 $type: "place.stream.chat.message",
49 },
50 indexedAt: now.toISOString(),
51 chatProfile: {
52 color: { red: 128, green: 128, blue: 128 }, // Gray color for system messages
53 $type: "place.stream.chat.profile",
54 },
55 };
56};
57
58/**
59 * System message factory functions for common scenarios
60 */
61export const SystemMessages = {
62 streamStart: (streamerName: string): ChatMessageViewHydrated =>
63 createSystemMessage(
64 SystemMessageType.stream_start,
65 `Now streaming - ${streamerName}`,
66 {
67 streamerName,
68 },
69 ),
70
71 // technically, streams can't 'end' on Streamplace
72 // possibly we could use deleting or editing streams (`endedAt` param) for this?
73 streamEnd: (duration?: string): ChatMessageViewHydrated =>
74 createSystemMessage(
75 SystemMessageType.stream_end,
76 duration ? `Stream has ended. Duration: ${duration}` : "Stream has ended",
77 { duration },
78 ),
79
80 teleportArrival: (
81 streamerName: string,
82 streamerDid: string,
83 count: number,
84 chatProfile?: any,
85 ): ChatMessageViewHydrated => {
86 const text =
87 count > 0
88 ? `${count} viewer${count !== 1 ? "s" : ""} teleported from ${streamerName}'s stream! Say hi!`
89 : `Someone teleported from ${streamerName}'s stream! Say hi!`;
90 const message = createSystemMessage(SystemMessageType.notification, text, {
91 streamerName,
92 count,
93 });
94
95 // create a mention facet for the streamer name so it gets colored using existing mention rendering
96 if (chatProfile && streamerDid) {
97 const nameStart = text.indexOf(streamerName);
98
99 // encode byte positions
100 const encoder = new TextEncoder();
101 const byteStart = encoder.encode(text.substring(0, nameStart)).length;
102 const byteEnd = byteStart + encoder.encode(streamerName).length;
103
104 message.record.facets = [
105 {
106 index: {
107 byteStart,
108 byteEnd,
109 },
110 features: [
111 {
112 $type: "app.bsky.richtext.facet#mention",
113 did: streamerDid,
114 },
115 ],
116 },
117 ];
118 }
119
120 return message;
121 },
122
123 notification: (message: string): ChatMessageViewHydrated =>
124 createSystemMessage(SystemMessageType.notification, message),
125
126 commandError: (message: string): ChatMessageViewHydrated =>
127 createSystemMessage(SystemMessageType.command_error, message),
128};
129
130/**
131 * Checks if a message is a system message
132 * @param message The message to check
133 * @returns True if the message is a system message
134 */
135export const isSystemMessage = (message: ChatMessageViewHydrated): boolean => {
136 return message.author.did === "did:sys:system";
137};
138
139/**
140 * Gets the system message type from a message
141 * @param message The message to check
142 * @returns The system message type or null if not a system message
143 */
144export const getSystemMessageType = (
145 message: ChatMessageViewHydrated,
146): SystemMessageType | null => {
147 if (!isSystemMessage(message)) {
148 return null;
149 }
150 return message.author.handle as SystemMessageType;
151};
152
153/**
154 * Parses metadata from a system message based on its type
155 * @param message The system message to parse
156 * @returns The parsed metadata
157 */
158export const parseSystemMessageMetadata = (
159 message: ChatMessageViewHydrated,
160): SystemMessageMetadata => {
161 const metadata: SystemMessageMetadata = {};
162 const type = getSystemMessageType(message);
163 const text = message.record.text;
164
165 if (!type) return metadata;
166
167 switch (type) {
168 case "stream_end": {
169 const durationMatch = text.match(/Duration:\s*(\d+:\d+(?::\d+)?)/);
170 if (durationMatch) {
171 metadata.duration = durationMatch[1];
172 }
173 break;
174 }
175
176 case "stream_start": {
177 const streamerMatch = text.match(/^(.+?)\s+is now live!/);
178 if (streamerMatch) {
179 metadata.streamerName = streamerMatch[1];
180 }
181 break;
182 }
183 }
184
185 return metadata;
186};