@@ -16,6 +16,7 @@ import (
16
16
"os"
17
17
"os/signal"
18
18
"path"
19
+ "regexp"
19
20
"strconv"
20
21
"strings"
21
22
"syscall"
42
43
"https://bsky.social" ), "bluesky PDS server URL" )
43
44
watchWord = flag .String ("watch-word" , envOr ("WATCH_WORD" , "tailscale" ),
44
45
"the word to watch out for. may be multiple words in future (required)" )
45
-
46
+ watchWords = flag .String ("watch-words" , envOr ("WATCH_WORDS" , "" ), // "word1;word2"
47
+ "the words to watch out for. if watch-word is also specified, they are added" )
48
+ delimiter = flag .String ("word-delimiter" , envOr ("WORD_DELIMITER" , ";" ),
49
+ "the character(s) that multi-word options are split by." )
46
50
secretsURL = flag .String ("secrets-url" , envOr ("SECRETS_URL" , "" ),
47
51
"the URL of a secrets server (if empty, no server is used)" )
48
52
secretsPrefix = flag .String ("secrets-prefix" , envOr ("SECRETS_PREFIX" , "" ),
51
55
"the Tailscale hostname the server should advertise (if empty, runs locally)" )
52
56
tsStateDir = flag .String ("ts-state-dir" , envOr ("TS_STATE_DIR" , "" ),
53
57
"the Tailscale state directory path (optional)" )
58
+ caseSensitive = flag .Bool ("case-sensitive-words" , hasEnv ("CASE_SENSITIVE_WORDS" ),
59
+ "make watch words case-sensitive" )
60
+ enforceWordBoundary = flag .Bool ("enforce-word-boundary" , hasEnv ("ENFORCE_WORD_BOUNDARY" ),
61
+ "only match \" whole words\" , \\ b in regex" )
54
62
)
55
63
56
64
// Public addresses of jetstream websocket services.
@@ -92,8 +100,40 @@ func main() {
92
100
log .Fatal ("Missing Bluesky account handle (BSKY_HANDLE)" )
93
101
case * bskyAppKey == "" && * secretsURL == "" :
94
102
log .Fatal ("missing Bluesky app secret (BSKY_APP_PASSWORD)" )
95
- case * watchWord == "" :
96
- log .Fatal ("missing watchword" )
103
+ case * watchWord == "" && * watchWords == "" :
104
+ log .Fatal ("missing watchWord and watchWords (WATCH_WORD / WATCH_WORDS)" )
105
+ }
106
+
107
+ words := strings .Split (* watchWords , * delimiter )
108
+ words = append (words , * watchWord )
109
+
110
+ if len (words ) == 0 {
111
+ log .Fatal ("no words! nothing to do!" )
112
+ }
113
+
114
+ // build the regular expression for matching words
115
+ var rb strings.Builder
116
+ if * enforceWordBoundary {
117
+ rb .WriteString ("\\ b(" )
118
+ }
119
+ if ! * caseSensitive {
120
+ rb .WriteString ("(?i)" )
121
+ }
122
+
123
+ // prepare the words for being compiled into regex
124
+ for i , v := range words {
125
+ words [i ] = "(" + regexp .QuoteMeta (v ) + ")"
126
+ }
127
+
128
+ rb .WriteString (strings .Join (words , "|" ))
129
+ if * enforceWordBoundary {
130
+ rb .WriteString (")\\ b" )
131
+ }
132
+
133
+ log .Print (rb .String ())
134
+ wordRx , err := regexp .Compile (rb .String ())
135
+ if err != nil {
136
+ log .Fatalf ("compile regex: %v" , err )
97
137
}
98
138
99
139
ctx , cancel := signal .NotifyContext (context .Background (), syscall .SIGINT , syscall .SIGTERM )
@@ -139,14 +179,19 @@ func main() {
139
179
}
140
180
slog .Info ("ws connecting" , "url" , wsURL .String ())
141
181
142
- err := websocketConnection (ctx , wsURL )
182
+ err := websocketConnection (ctx , wsURL , * wordRx )
143
183
slog .Error ("ws connection" , "url" , wsURL , "err" , err )
144
184
145
185
// TODO(erisa): exponential backoff
146
186
time .Sleep (2 * time .Second )
147
187
}
148
188
}
149
189
190
+ func hasEnv (name string ) bool {
191
+ _ , ok := os .LookupEnv (name )
192
+ return ok
193
+ }
194
+
150
195
func envOr (key , defaultVal string ) string {
151
196
if result , ok := os .LookupEnv (key ); ok {
152
197
return result
@@ -171,7 +216,7 @@ func nextWSAddress() func() string {
171
216
}
172
217
}
173
218
174
- func websocketConnection (ctx context.Context , wsUrl url.URL ) error {
219
+ func websocketConnection (ctx context.Context , wsUrl url.URL , wordRx regexp. Regexp ) error {
175
220
// add compression headers
176
221
headers := http.Header {}
177
222
headers .Add ("Socket-Encoding" , "zstd" )
@@ -206,7 +251,7 @@ func websocketConnection(ctx context.Context, wsUrl url.URL) error {
206
251
return err
207
252
}
208
253
209
- err = readJetstreamMessage (ctx , jetstreamMessage , bsky )
254
+ err = readJetstreamMessage (ctx , jetstreamMessage , bsky , wordRx )
210
255
if err != nil {
211
256
msg := jetstreamMessage [:min (32 , len (jetstreamMessage ))]
212
257
log .Printf ("error reading jetstream message %q: %v" , msg , err )
@@ -216,7 +261,7 @@ func websocketConnection(ctx context.Context, wsUrl url.URL) error {
216
261
return ctx .Err ()
217
262
}
218
263
219
- func readJetstreamMessage (ctx context.Context , jetstreamMessageEncoded []byte , bsky * bluesky.Client ) error {
264
+ func readJetstreamMessage (ctx context.Context , jetstreamMessageEncoded []byte , bsky * bluesky.Client , wordRx regexp. Regexp ) error {
220
265
// Decompress the message
221
266
m , err := zstdDecoder .DecodeAll (jetstreamMessageEncoded , nil )
222
267
if err != nil {
@@ -247,7 +292,7 @@ func readJetstreamMessage(ctx context.Context, jetstreamMessageEncoded []byte, b
247
292
return nil
248
293
}
249
294
250
- if strings . Contains ( strings . ToLower ( bskyMessage .Commit .Record .Text ), strings . ToLower ( * watchWord ) ) {
295
+ if wordRx . MatchString ( bskyMessage .Commit .Record .Text ) {
251
296
jetstreamMessageStr := string (jetstreamMessage )
252
297
253
298
go func () {
@@ -258,7 +303,6 @@ func readJetstreamMessage(ctx context.Context, jetstreamMessageEncoded []byte, b
258
303
}
259
304
260
305
var imageURL string
261
-
262
306
if len (bskyMessage .Commit .Record .Embed .Images ) != 0 {
263
307
imageURL = fmt .Sprintf ("https://cdn.bsky.app/img/feed_fullsize/plain/%s/%s" , bskyMessage .DID , bskyMessage .Commit .Record .Embed .Images [0 ].Image .Ref .Link )
264
308
}
0 commit comments