+8
-4
.env.example
+8
-4
.env.example
···
1
-
JETSTREAM_URL=""
2
-
LABELER_URL=""
3
-
LABELER_KEY=""
4
-
REDIS_ADDR=""
1
+
PDS_URL="https://bsky.social"
2
+
ACCOUNT_HANDLE="your-handle.bsky.social"
3
+
ACCOUNT_PASSWORD="your-app-password"
4
+
WATCHED_OPS="did:plc:example1,did:plc:example2"
5
+
JETSTREAM_URL="wss://jetstream2.us-west.bsky.network/subscribe"
6
+
LABELER_URL="http://localhost:3000"
7
+
LABELER_KEY="your-secret-key-here"
8
+
LMSTUDIO_HOST="http://localhost:1234"
+7
LICENSE
+7
LICENSE
···
1
+
Copyright 2025 me@haileyok.com
2
+
3
+
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
4
+
5
+
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
6
+
7
+
THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+182
README.md
+182
README.md
···
1
+
# dontshowmethis
2
+
3
+
An AI-powered Bluesky content moderation system that automatically labels replies to monitored accounts using LLM-based content analysis.
4
+
5
+
## Overview
6
+
7
+
This project monitors Bluesky posts in real-time via Jetstream and uses a local LLM (via LM Studio) to classify replies to watched accounts. It automatically applies labels such as "bad-faith", "off-topic", and "funny" to help users filter and moderate content.
8
+
9
+
The system consists of two components:
10
+
1. **Go Consumer** - Monitors the Jetstream firehose and analyzes replies using an LLM
11
+
2. **Skyware Labeler** - TypeScript server that manages and emits content labels
12
+
13
+
## Architecture
14
+
15
+
```
16
+
Jetstream → Go Consumer → LM Studio (LLM) → Labeler Service → Bluesky
17
+
```
18
+
19
+
1. The Go consumer subscribes to Jetstream and monitors replies to specified accounts
20
+
2. When a reply is detected, it fetches the parent post and sends both to LM Studio
21
+
3. The LLM classifies the reply based on the system prompt
22
+
4. Labels are emitted via the Skyware labeler service
23
+
5. Labels are propagated to Bluesky's labeling system
24
+
25
+
## Prerequisites
26
+
27
+
- [LM Studio](https://lmstudio.ai/) with a compatible model loaded
28
+
- A Bluesky account for the labeler
29
+
30
+
## Installation
31
+
32
+
### Clone the repository
33
+
34
+
```bash
35
+
git clone https://github.com/haileyok/dontshowmethis.git
36
+
cd dontshowmethis
37
+
```
38
+
39
+
### Install Go dependencies
40
+
41
+
```bash
42
+
go mod download
43
+
```
44
+
45
+
### Install labeler dependencies
46
+
47
+
```bash
48
+
cd labeler
49
+
yarn install
50
+
cd ..
51
+
```
52
+
53
+
## Configuration
54
+
55
+
Copy the example environment file and configure it:
56
+
57
+
```bash
58
+
cp .env.example .env
59
+
```
60
+
61
+
### Environment Variables
62
+
63
+
**For the Go Consumer:**
64
+
65
+
- `PDS_URL` - Your Bluesky PDS URL (e.g., `https://bsky.social`)
66
+
- `ACCOUNT_HANDLE` - Your Bluesky account handle
67
+
- `ACCOUNT_PASSWORD` - Your Bluesky account password
68
+
- `WATCHED_OPS` - Comma-separated list of DIDs to monitor for replies
69
+
- `JETSTREAM_URL` - Jetstream WebSocket URL (default: `wss://jetstream2.us-west.bsky.network/subscribe`)
70
+
- `LABELER_URL` - URL of your labeler service (e.g., `http://localhost:3000`)
71
+
- `LABELER_KEY` - Authentication key for the labeler API
72
+
- `LMSTUDIO_HOST` - LM Studio API host (e.g., `http://localhost:1234`)
73
+
74
+
**For the Skyware Labeler:**
75
+
76
+
- `SKYWARE_DID` - Your labeler's DID
77
+
- `SKYWARE_SIG_KEY` - Your labeler's signing key
78
+
- `EMIT_LABEL_KEY` - Secret key for the emit label API (must match `LABELER_KEY` above)
79
+
80
+
## Running the Services
81
+
82
+
### 1. Start LM Studio
83
+
84
+
1. Open LM Studio
85
+
2. Load a compatible model (recommended: `google/gemma-3-27b` or similar)
86
+
3. Start the local server (usually runs on `http://localhost:1234`)
87
+
88
+
### 2. Start the Labeler Service
89
+
90
+
```bash
91
+
cd labeler
92
+
npm start
93
+
```
94
+
95
+
The labeler will start two servers:
96
+
- Port 14831: Skyware labeler server
97
+
- Port 3000: Label emission API
98
+
99
+
### 3. Start the Go Consumer
100
+
101
+
```bash
102
+
go run .
103
+
```
104
+
105
+
Or build and run:
106
+
107
+
```bash
108
+
go build -o dontshowmethis
109
+
./dontshowmethis
110
+
```
111
+
112
+
## Usage
113
+
114
+
Once running, the system will:
115
+
116
+
1. Connect to Jetstream and monitor the firehose
117
+
2. Watch for replies to accounts specified in `WATCHED_OPS`
118
+
3. Automatically analyze and label qualifying replies
119
+
4. Log all actions to stdout
120
+
121
+
### Finding Account DIDs
122
+
123
+
To monitor specific accounts, you need their DIDs. You can find a DID by:
124
+
125
+
```bash
126
+
curl "https://bsky.social/xrpc/com.atproto.identity.resolveHandle?handle=username.bsky.social"
127
+
```
128
+
129
+
Add the returned DID to your `WATCHED_OPS` environment variable.
130
+
131
+
## How Content Classification Works
132
+
133
+
See `lmstudio.go:147` for the system prompt.
134
+
135
+
## Development
136
+
137
+
### Project Structure
138
+
139
+
```
140
+
.
141
+
├── main.go # CLI setup and consumer initialization
142
+
├── handle_post.go # Post handling and labeling logic
143
+
├── lmstudio.go # LLM client and content classification
144
+
├── sets/
145
+
│ └── domains.go # Political domain list (currently unused)
146
+
├── labeler/
147
+
│ ├── index.ts # Skyware labeler service
148
+
│ └── package.json # Labeler dependencies
149
+
├── .env.example # Example environment configuration
150
+
└── README.md # This file
151
+
```
152
+
153
+
### Adding New Labels
154
+
155
+
1. Add the label constant in `main.go`:
156
+
```go
157
+
const LabelNewLabel = "new-label"
158
+
```
159
+
160
+
2. Add it to the labeler's allowed labels in `labeler/index.ts`:
161
+
```typescript
162
+
const LABELS: Record<string, boolean> = {
163
+
'bad-faith': true,
164
+
'off-topic': true,
165
+
'funny': true,
166
+
'new-label': true, // Add here
167
+
}
168
+
```
169
+
170
+
3. Update the LLM schema in `lmstudio.go` to include the new classification
171
+
172
+
4. Update the handling logic in `handle_post.go` to emit the new label
173
+
174
+
## License
175
+
176
+
MIT
177
+
178
+
## Acknowledgments
179
+
180
+
- [Jetstream](https://github.com/bluesky-social/jetstream) - Real-time firehose for Bluesky
181
+
- [Skyware](https://skyware.js.org/) - Labeler framework
182
+
- [LM Studio](https://lmstudio.ai/) - Local LLM inference
+4
-4
go.mod
+4
-4
go.mod
···
1
1
module github.com/haileyok/dontshowmethis
2
2
3
-
go 1.23
4
-
5
-
toolchain go1.23.8
3
+
go 1.24
6
4
7
5
require (
8
-
github.com/bluesky-social/indigo v0.0.0-20250425223227-c6066a8845a0
6
+
github.com/bluesky-social/indigo v0.0.0-20251010014239-c74e8a3208cf
9
7
github.com/bluesky-social/jetstream v0.0.0-20250414024304-d17bd81a945e
8
+
github.com/hashicorp/golang-lru/v2 v2.0.7
10
9
github.com/joho/godotenv v1.5.1
11
10
github.com/urfave/cli/v2 v2.27.6
12
11
)
···
17
16
github.com/cespare/xxhash/v2 v2.3.0 // indirect
18
17
github.com/cpuguy83/go-md2man/v2 v2.0.5 // indirect
19
18
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
19
+
github.com/earthboundkid/versioninfo/v2 v2.24.1 // indirect
20
20
github.com/felixge/httpsnoop v1.0.4 // indirect
21
21
github.com/go-logr/logr v1.4.1 // indirect
22
22
github.com/go-logr/stdr v1.2.2 // indirect
+6
go.sum
+6
go.sum
···
4
4
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
5
5
github.com/bluesky-social/indigo v0.0.0-20250425223227-c6066a8845a0 h1:0OdsA6vnOYAyPAlYTb0UKx7oRaB5mLlNV7cq5+71Fnc=
6
6
github.com/bluesky-social/indigo v0.0.0-20250425223227-c6066a8845a0/go.mod h1:ovyxp8AMO1Hoe838vMJUbqHTZaAR8ABM3g3TXu+A5Ng=
7
+
github.com/bluesky-social/indigo v0.0.0-20251010014239-c74e8a3208cf h1:67NJXFBEXS60GBJUgfBBidEAkEHPtM+lwKqVHqHSj9I=
8
+
github.com/bluesky-social/indigo v0.0.0-20251010014239-c74e8a3208cf/go.mod h1:GuGAU33qKulpZCZNPcUeIQ4RW6KzNvOy7s8MSUXbAng=
7
9
github.com/bluesky-social/jetstream v0.0.0-20250414024304-d17bd81a945e h1:P/O6TDHs53gwgV845uDHI+Nri889ixksRrh4bCkCdxo=
8
10
github.com/bluesky-social/jetstream v0.0.0-20250414024304-d17bd81a945e/go.mod h1:WiYEeyJSdUwqoaZ71KJSpTblemUCpwJfh5oVXplK6T4=
9
11
github.com/carlmjohnson/versioninfo v0.22.5 h1:O00sjOLUAFxYQjlN/bzYTuZiS0y6fWDQjMRvwtKgwwc=
···
18
20
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
19
21
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
20
22
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
23
+
github.com/earthboundkid/versioninfo/v2 v2.24.1 h1:SJTMHaoUx3GzjjnUO1QzP3ZXK6Ee/nbWyCm58eY3oUg=
24
+
github.com/earthboundkid/versioninfo/v2 v2.24.1/go.mod h1:VcWEooDEuyUJnMfbdTh0uFN4cfEIg+kHMuWB2CDCLjw=
21
25
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
22
26
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
23
27
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
···
47
51
github.com/hashicorp/go-retryablehttp v0.7.5/go.mod h1:Jy/gPYAdjqffZ/yFGCFV2doI5wjtH1ewM9u8iYVjtX8=
48
52
github.com/hashicorp/golang-lru v1.0.2 h1:dV3g9Z/unq5DpblPpw+Oqcv4dU/1omnb4Ok8iPY6p1c=
49
53
github.com/hashicorp/golang-lru v1.0.2/go.mod h1:iADmTwqILo4mZ8BN3D2Q6+9jd8WM5uGBxy+E8yxSoD4=
54
+
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
55
+
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
50
56
github.com/ipfs/bbloom v0.0.4 h1:Gi+8EGJ2y5qiD5FbsbpX/TMNcJw8gSqr7eyjHa4Fhvs=
51
57
github.com/ipfs/bbloom v0.0.4/go.mod h1:cS9YprKXpoZ9lT0n/Mw/a6/aFV6DTjTLYHeA+gyqMG0=
52
58
github.com/ipfs/go-block-format v0.2.0 h1:ZqrkxBA2ICbDRbK8KJs/u0O3dlp6gmAuuXUJNiW1Ycs=
+53
-47
handle_post.go
+53
-47
handle_post.go
···
3
3
import (
4
4
"context"
5
5
"fmt"
6
-
"net/url"
7
-
"strings"
6
+
"time"
8
7
9
8
"github.com/bluesky-social/indigo/api/bsky"
9
+
"github.com/bluesky-social/indigo/atproto/syntax"
10
10
"github.com/bluesky-social/jetstream/pkg/models"
11
-
"github.com/haileyok/dontshowmethis/sets"
12
-
"github.com/redis/go-redis/v9"
13
11
)
14
12
15
-
func uriFromEvent(evt *models.Event) string {
16
-
return fmt.Sprintf("at://%s/%s/%s", evt.Did, evt.Commit.Collection, evt.Commit.RKey)
17
-
}
18
-
19
13
func (dsmt *DontShowMeThis) handlePost(ctx context.Context, event *models.Event, post *bsky.FeedPost) error {
20
14
if event == nil || event.Commit == nil {
21
15
return nil
22
16
}
23
17
24
-
if post.Embed == nil && post.Reply == nil && len(post.Facets) == 0 {
18
+
var parentUri string
19
+
20
+
if post.Reply != nil && post.Reply.Parent != nil {
21
+
parentUri = post.Reply.Parent.Uri
22
+
} else if post.Embed != nil && post.Embed.EmbedRecord != nil && post.Embed.EmbedRecord.Record != nil {
23
+
parentUri = post.Embed.EmbedRecord.Record.Uri
24
+
} else if post.Embed != nil && post.Embed.EmbedRecordWithMedia != nil && post.Embed.EmbedRecordWithMedia.Record != nil && post.Embed.EmbedRecordWithMedia.Record.Record != nil {
25
+
parentUri = post.Embed.EmbedRecordWithMedia.Record.Record.Uri
26
+
}
27
+
28
+
if parentUri == "" {
25
29
return nil
26
30
}
27
31
28
-
if post.Embed != nil && post.Embed.EmbedExternal != nil && post.Embed.EmbedExternal.External != nil {
29
-
external := post.Embed.EmbedExternal.External
32
+
atUri, err := syntax.ParseATURI(parentUri)
33
+
if err != nil {
34
+
return fmt.Errorf("failed to parse parent aturi: %w", err)
35
+
}
30
36
31
-
u, err := url.Parse(external.Uri)
32
-
if err != nil {
33
-
return err
34
-
}
37
+
opDid := atUri.Authority().String()
38
+
_, ok := dsmt.watchedOps[opDid]
39
+
if !ok {
40
+
return nil
41
+
}
42
+
43
+
uri := fmt.Sprintf("at://%s/%s/%s", event.Did, event.Commit.Collection, event.Commit.RKey)
44
+
45
+
logger := dsmt.logger.With("opDid", opDid, "replyDid", event.Did, "uri", uri)
35
46
36
-
domain := strings.ToLower(u.Hostname())
47
+
logger.Info("ingested reply to watched op")
37
48
38
-
if sets.PolDomains[domain] {
39
-
if err := dsmt.emitLabel(ctx, uriFromEvent(event), LabelPolLink); err != nil {
40
-
return err
41
-
}
42
-
return nil
43
-
}
49
+
if post.Text == "" {
50
+
logger.Info("post contained no text, skipping")
51
+
return nil
44
52
}
45
53
46
-
for _, f := range post.Facets {
47
-
for _, ff := range f.Features {
48
-
if ff.RichtextFacet_Link == nil {
49
-
continue
50
-
}
54
+
parent, err := dsmt.getPost(ctx, parentUri)
55
+
if err != nil {
56
+
return fmt.Errorf("failed to get parent post: %w", err)
57
+
}
51
58
52
-
u, err := url.Parse(ff.RichtextFacet_Link.Uri)
53
-
if err != nil {
54
-
return err
55
-
}
59
+
ctx, cancel := context.WithTimeout(ctx, 30*time.Second)
60
+
defer cancel()
56
61
57
-
domain := strings.ToLower(u.Hostname())
62
+
results, err := dsmt.lmstudioc.GetIsBadFaith(ctx, parent.Text, post.Text)
63
+
if err != nil {
64
+
return fmt.Errorf("failed to check bad faith: %w", err)
65
+
}
58
66
59
-
if sets.PolDomains[domain] {
60
-
if err := dsmt.emitLabel(ctx, uriFromEvent(event), LabelPolLink); err != nil {
61
-
return err
62
-
}
63
-
return nil
64
-
}
67
+
if results.BadFaith {
68
+
if err := dsmt.emitLabel(ctx, uri, LabelBadFaith); err != nil {
69
+
return fmt.Errorf("failed to label post: %w", err)
65
70
}
71
+
logger.Info("determined that reply was bad faith and emitted label")
66
72
}
67
73
68
-
if post.Reply != nil {
69
-
ism, err := dsmt.r.SIsMember(ctx, RedisPrefix+LabelPolLink, post.Reply.Root.Uri).Result()
70
-
if err != nil && err != redis.Nil {
71
-
return err
74
+
if results.OffTopic {
75
+
if err := dsmt.emitLabel(ctx, uri, LabelOffTopic); err != nil {
76
+
return fmt.Errorf("failed to label post: %w", err)
72
77
}
78
+
logger.Info("determined that reply was off topic and emitted label")
79
+
}
73
80
74
-
if ism {
75
-
if err := dsmt.emitLabel(ctx, uriFromEvent(event), LabelPolLinkReply); err != nil {
76
-
return err
77
-
}
78
-
return nil
81
+
if results.Funny {
82
+
if err := dsmt.emitLabel(ctx, uri, LabelFunny); err != nil {
83
+
return fmt.Errorf("failed to label post: %w", err)
79
84
}
85
+
logger.Info("determined that reply was funny and emitted label")
80
86
}
81
87
82
88
return nil
+3
-2
labeler/index.ts
+3
-2
labeler/index.ts
+199
lmstudio.go
+199
lmstudio.go
···
1
+
package main
2
+
3
+
import (
4
+
"bytes"
5
+
"context"
6
+
"encoding/json"
7
+
"fmt"
8
+
"io"
9
+
"log/slog"
10
+
"net/http"
11
+
12
+
"github.com/bluesky-social/indigo/pkg/robusthttp"
13
+
)
14
+
15
+
type LMStudioClient struct {
16
+
host string
17
+
httpc *http.Client
18
+
logger *slog.Logger
19
+
}
20
+
type ResponseSchema struct {
21
+
Type string `json:"type"`
22
+
Properties map[string]Property `json:"properties"`
23
+
Required []string `json:"required"`
24
+
}
25
+
26
+
type Property struct {
27
+
Type string `json:"type"`
28
+
Description string `json:"description,omitempty"`
29
+
Enum []string `json:"enum,omitempty"`
30
+
}
31
+
32
+
type ChatRequest struct {
33
+
Model string `json:"model"`
34
+
Messages []Message `json:"messages"`
35
+
Temperature float64 `json:"temperature,omitempty"`
36
+
MaxTokens int `json:"max_tokens,omitempty"`
37
+
ResponseFormat *ResponseFormat `json:"response_format,omitempty"`
38
+
}
39
+
40
+
type Message struct {
41
+
Role string `json:"role"`
42
+
Content string `json:"content"`
43
+
}
44
+
45
+
type ResponseFormat struct {
46
+
Type string `json:"type"`
47
+
JSONSchema *JSONSchemaWrap `json:"json_schema,omitempty"`
48
+
}
49
+
50
+
type JSONSchemaWrap struct {
51
+
Name string `json:"name"`
52
+
Schema ResponseSchema `json:"schema"`
53
+
Strict bool `json:"strict"`
54
+
}
55
+
56
+
type ChatResponse struct {
57
+
ID string `json:"id"`
58
+
Object string `json:"object"`
59
+
Created int64 `json:"created"`
60
+
Model string `json:"model"`
61
+
Choices []Choice `json:"choices"`
62
+
}
63
+
64
+
type Choice struct {
65
+
Index int `json:"index"`
66
+
Message Message `json:"message"`
67
+
FinishReason string `json:"finish_reason"`
68
+
}
69
+
70
+
var (
71
+
schema = ResponseSchema{
72
+
Type: "object",
73
+
Properties: map[string]Property{
74
+
"bad_faith": {
75
+
Type: "boolean",
76
+
Description: "Whether the reply to the parent is bad faith or not.",
77
+
},
78
+
"off_topic": {
79
+
Type: "boolean",
80
+
Description: "Whether the reply to the parent is off topic.",
81
+
},
82
+
"funny": {
83
+
Type: "boolean",
84
+
Description: "Whether the reply to the parent is funny.",
85
+
},
86
+
},
87
+
Required: []string{"bad_faith", "off_topic", "funny"},
88
+
}
89
+
)
90
+
91
+
func NewLMStudioClient(host string, logger *slog.Logger) *LMStudioClient {
92
+
if logger == nil {
93
+
logger = slog.Default()
94
+
}
95
+
logger = logger.With("component", "lmstudio")
96
+
httpc := robusthttp.NewClient()
97
+
return &LMStudioClient{
98
+
host: host,
99
+
httpc: httpc,
100
+
logger: logger,
101
+
}
102
+
}
103
+
104
+
func (c *LMStudioClient) sendChatRequest(request ChatRequest) (*ChatResponse, error) {
105
+
url := fmt.Sprintf("%s/v1/chat/completions", c.host)
106
+
107
+
b, err := json.Marshal(request)
108
+
if err != nil {
109
+
return nil, fmt.Errorf("error marshaling request: %w", err)
110
+
}
111
+
112
+
resp, err := http.Post(url, "application/json", bytes.NewReader(b))
113
+
if err != nil {
114
+
return nil, fmt.Errorf("error sending request: %w", err)
115
+
}
116
+
defer resp.Body.Close()
117
+
118
+
body, err := io.ReadAll(resp.Body)
119
+
if err != nil {
120
+
return nil, fmt.Errorf("error reading response: %w", err)
121
+
}
122
+
123
+
if resp.StatusCode != http.StatusOK {
124
+
return nil, fmt.Errorf("bad status code: %d - %s", resp.StatusCode, string(body))
125
+
}
126
+
127
+
var chatResp ChatResponse
128
+
if err := json.Unmarshal(body, &chatResp); err != nil {
129
+
return nil, fmt.Errorf("error unmarshaling response: %w", err)
130
+
}
131
+
132
+
return &chatResp, nil
133
+
}
134
+
135
+
type BadFaithResults struct {
136
+
BadFaith bool
137
+
OffTopic bool
138
+
Funny bool
139
+
}
140
+
141
+
func (c *LMStudioClient) GetIsBadFaith(ctx context.Context, parent, reply string) (*BadFaithResults, error) {
142
+
request := ChatRequest{
143
+
Model: "google/gemma-3-27b",
144
+
Messages: []Message{
145
+
{
146
+
Role: "system",
147
+
Content: "You are an observer of posts on a microblogging website. You determine if the second message provided by the user is a bad faith reply, an off topic reply, and/or a funny reply to the second message provided to you. Opposing viewpoints are good, and should be appreciated. However, things that are toxic, trollish, or offer no good value to the conversation are considered bad faith. Just because something is bad faith or off topic does not mean the post cannot also be funny.",
148
+
},
149
+
{
150
+
Role: "user",
151
+
Content: parent,
152
+
},
153
+
{
154
+
Role: "user",
155
+
Content: reply,
156
+
},
157
+
},
158
+
Temperature: 0.7,
159
+
MaxTokens: 100,
160
+
ResponseFormat: &ResponseFormat{
161
+
Type: "json_schema",
162
+
JSONSchema: &JSONSchemaWrap{
163
+
Name: "message_classification",
164
+
Schema: schema,
165
+
Strict: true,
166
+
},
167
+
},
168
+
}
169
+
response, err := c.sendChatRequest(request)
170
+
if err != nil {
171
+
return nil, fmt.Errorf("failed to get chat response: %w", err)
172
+
}
173
+
174
+
var result map[string]any
175
+
if err := json.Unmarshal([]byte(response.Choices[0].Message.Content), &result); err != nil {
176
+
return nil, fmt.Errorf("failed to unmarshal response: %w", err)
177
+
}
178
+
179
+
badFaith, ok := result["bad_faith"].(bool)
180
+
if !ok {
181
+
return nil, fmt.Errorf("model gave bad response (bad faith), not structured")
182
+
}
183
+
184
+
offTopic, ok := result["off_topic"].(bool)
185
+
if !ok {
186
+
return nil, fmt.Errorf("model gave bad response (off topic), not structured")
187
+
}
188
+
189
+
funny, ok := result["funny"].(bool)
190
+
if !ok {
191
+
return nil, fmt.Errorf("model gave bad response (funny), not structured")
192
+
}
193
+
194
+
return &BadFaithResults{
195
+
BadFaith: badFaith,
196
+
OffTopic: offTopic,
197
+
Funny: funny,
198
+
}, nil
199
+
}
+112
-39
main.go
+112
-39
main.go
···
10
10
"log/slog"
11
11
"net/http"
12
12
"os"
13
+
"time"
13
14
14
15
"github.com/bluesky-social/indigo/api/bsky"
15
16
"github.com/bluesky-social/indigo/util"
···
17
18
"github.com/bluesky-social/jetstream/pkg/client"
18
19
"github.com/bluesky-social/jetstream/pkg/client/schedulers/sequential"
19
20
"github.com/bluesky-social/jetstream/pkg/models"
21
+
lru "github.com/hashicorp/golang-lru/v2/expirable"
20
22
_ "github.com/joho/godotenv/autoload"
21
-
"github.com/redis/go-redis/v9"
22
23
"github.com/urfave/cli/v2"
23
24
)
24
25
25
26
const (
26
-
RedisPrefix = "dsmt/"
27
-
LabelPolLink = "pol-link"
28
-
LabelPolLinkReply = "pol-link-reply"
27
+
LabelBadFaith = "bad-faith"
28
+
LabelOffTopic = "off-topic"
29
+
LabelFunny = "funny"
29
30
)
30
31
31
32
func main() {
···
34
35
Action: run,
35
36
Flags: []cli.Flag{
36
37
&cli.StringFlag{
38
+
Name: "pds-url",
39
+
EnvVars: []string{"PDS_URL"},
40
+
Required: true,
41
+
},
42
+
&cli.StringFlag{
43
+
Name: "account-handle",
44
+
EnvVars: []string{"ACCOUNT_HANDLE"},
45
+
Required: true,
46
+
},
47
+
&cli.StringFlag{
48
+
Name: "account-password",
49
+
EnvVars: []string{"ACCOUNT_PASSWORD"},
50
+
Required: true,
51
+
},
52
+
&cli.StringSliceFlag{
53
+
Name: "watched-ops",
54
+
EnvVars: []string{"WATCHED_OPS"},
55
+
Required: true,
56
+
},
57
+
&cli.StringFlag{
37
58
Name: "jetstream-url",
38
59
EnvVars: []string{"JETSTREAM_URL"},
39
60
Value: "wss://jetstream2.us-west.bsky.network/subscribe",
···
51
72
EnvVars: []string{"LABELER_KEY"},
52
73
},
53
74
&cli.StringFlag{
54
-
Name: "redis-addr",
55
-
Usage: "redis addr",
75
+
Name: "lmstudio-host",
76
+
Usage: "lmstudio host",
77
+
EnvVars: []string{"LMSTUDIO_HOST"},
56
78
Required: true,
57
-
EnvVars: []string{"REDIS_ADDR"},
58
79
},
59
80
},
60
81
}
···
63
84
}
64
85
65
86
type DontShowMeThis struct {
66
-
logger *slog.Logger
67
-
bskyClient *xrpc.Client
68
-
h *http.Client
69
-
r *redis.Client
87
+
logger *slog.Logger
88
+
xrpcc *xrpc.Client
89
+
httpc *http.Client
90
+
91
+
watchedOps map[string]struct{}
70
92
71
93
labelerUrl string
72
94
labelerKey string
95
+
96
+
lmstudioc *LMStudioClient
97
+
98
+
postCache *lru.LRU[string, *bsky.FeedPost]
73
99
}
74
100
75
101
var run = func(cmd *cli.Context) error {
76
-
dsmt := &DontShowMeThis{
77
-
logger: slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{
78
-
Level: slog.LevelInfo,
79
-
AddSource: true,
80
-
})),
81
-
labelerUrl: cmd.String("labeler-url"),
82
-
labelerKey: cmd.String("labeler-key"),
102
+
opt := struct {
103
+
PdsUrl string
104
+
JetstreamUrl string
105
+
AccountHandle string
106
+
AccountPassword string
107
+
WatchedOps []string
108
+
LabelerUrl string
109
+
LabelerKey string
110
+
LmstudioHost string
111
+
}{
112
+
PdsUrl: cmd.String("pds-url"),
113
+
JetstreamUrl: cmd.String("jetstream-url"),
114
+
AccountHandle: cmd.String("account-handle"),
115
+
AccountPassword: cmd.String("account-password"),
116
+
WatchedOps: cmd.StringSlice("watched-ops"),
117
+
LabelerUrl: cmd.String("labeler-url"),
118
+
LabelerKey: cmd.String("labeler-key"),
119
+
LmstudioHost: cmd.String("lmstudio-host"),
120
+
}
121
+
122
+
logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{
123
+
Level: slog.LevelInfo,
124
+
}))
125
+
126
+
watchedOps := make(map[string]struct{}, len(opt.WatchedOps))
127
+
for _, op := range opt.WatchedOps {
128
+
watchedOps[op] = struct{}{}
83
129
}
84
130
85
-
cli := &xrpc.Client{
86
-
Host: cmd.String("pds"),
87
-
Headers: make(map[string]string),
88
-
Auth: &xrpc.AuthInfo{},
131
+
xrpcc := &xrpc.Client{
132
+
Host: opt.PdsUrl,
133
+
// Headers: make(map[string]string),
134
+
// Auth: &xrpc.AuthInfo{},
89
135
}
90
136
91
-
dsmt.bskyClient = cli
137
+
httpc := util.RobustHTTPClient()
92
138
93
-
dsmt.h = util.RobustHTTPClient()
139
+
lmstudioc := NewLMStudioClient(opt.LmstudioHost, logger)
94
140
95
-
r := redis.NewClient(&redis.Options{
96
-
Addr: cmd.String("redis-addr"),
97
-
})
141
+
postCache := lru.NewLRU[string, *bsky.FeedPost](100, nil, 1*time.Hour)
98
142
99
-
dsmt.r = r
143
+
dsmt := &DontShowMeThis{
144
+
logger: slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{
145
+
Level: slog.LevelInfo,
146
+
AddSource: true,
147
+
})),
148
+
labelerUrl: opt.LabelerUrl,
149
+
labelerKey: opt.LabelerKey,
150
+
watchedOps: watchedOps,
151
+
xrpcc: xrpcc,
152
+
httpc: httpc,
153
+
lmstudioc: lmstudioc,
154
+
postCache: postCache,
155
+
}
100
156
101
157
dsmt.startConsumer(cmd.String("jetstream-url"))
102
158
103
159
return nil
104
160
}
105
161
106
-
func (dsmt *DontShowMeThis) startConsumer(jsurl string) {
162
+
func (dsmt *DontShowMeThis) startConsumer(jetstreamUrl string) {
107
163
config := client.DefaultClientConfig()
108
-
config.WebsocketURL = jsurl
164
+
config.WebsocketURL = jetstreamUrl
109
165
config.Compress = true
110
166
111
167
scheduler := sequential.NewScheduler("jetstream_localdev", dsmt.logger, dsmt.handleEvent)
···
120
176
}
121
177
122
178
dsmt.logger.Info("shutdown")
123
-
}
124
-
125
-
type handler struct {
126
-
seenSeqs map[int64]struct{}
127
-
highwater int64
128
179
}
129
180
130
181
func (dsmt *DontShowMeThis) handleEvent(ctx context.Context, event *models.Event) error {
···
168
219
req.Header.Set("authorization", "Bearer "+dsmt.labelerKey)
169
220
req.Header.Set("content-type", "application/json")
170
221
171
-
resp, err := dsmt.h.Do(req)
222
+
resp, err := dsmt.httpc.Do(req)
172
223
if err != nil {
173
224
return err
174
225
}
···
180
231
return fmt.Errorf("received invalid status code from server: %d", resp.StatusCode)
181
232
}
182
233
183
-
if _, err := dsmt.r.SAdd(ctx, RedisPrefix+label, uri).Result(); err != nil {
184
-
return err
234
+
return nil
235
+
}
236
+
237
+
func (dsmt *DontShowMeThis) getPost(ctx context.Context, uri string) (*bsky.FeedPost, error) {
238
+
post, ok := dsmt.postCache.Get(uri)
239
+
if ok {
240
+
return post, nil
241
+
}
242
+
243
+
resp, err := bsky.FeedGetPosts(ctx, dsmt.xrpcc, []string{uri})
244
+
if err != nil {
245
+
return nil, fmt.Errorf("failed to get post: %w", err)
246
+
}
247
+
248
+
if resp == nil || len(resp.Posts) == 0 {
249
+
return nil, fmt.Errorf("failed to get posts (empty response)")
185
250
}
186
251
187
-
return nil
252
+
postView := resp.Posts[0]
253
+
post, ok = postView.Record.Val.(*bsky.FeedPost)
254
+
if !ok {
255
+
return nil, fmt.Errorf("failed to get post (invalid record)")
256
+
}
257
+
258
+
dsmt.postCache.Add(uri, post)
259
+
260
+
return post, nil
188
261
}
-61
sets/domains.go
-61
sets/domains.go
···
1
-
package sets
2
-
3
-
var PolDomains = map[string]bool{
4
-
"nytimes.com": true,
5
-
"theguardian.com": true,
6
-
"open.substack.com": true,
7
-
"npr.org": true,
8
-
"cnn.com": true,
9
-
"washingtonpost.com": true,
10
-
"wsj.com": true,
11
-
"newrepublic.com": true,
12
-
"apnews.com": true,
13
-
"politico.com": true,
14
-
"reut.rs": true,
15
-
"reuters.com": true,
16
-
"nbcnews.com": true,
17
-
"kfor.com": true,
18
-
"huffpost.com": true,
19
-
"rollingstone.com": true,
20
-
"rawstory.com": true,
21
-
"cbsnews.com": true,
22
-
"bbc.com": true,
23
-
"cbc.ca": true,
24
-
"nymag.com": true,
25
-
"wapo.st": true,
26
-
"newsweek.com": true,
27
-
"theatlantic.com": true,
28
-
"variety.com": true,
29
-
"nyti.ms": true,
30
-
"talkingpointsmemo.com": true,
31
-
"thedailybeast.com": true,
32
-
"axios.com": true,
33
-
"publicnotice.co": true,
34
-
"semafor.com": true,
35
-
"msnbc.com": true,
36
-
"liberation.fr": true,
37
-
"newyorker.com": true,
38
-
"independent.co.uk": true,
39
-
"whitehouse.gov": true,
40
-
"motherjones.com": true,
41
-
"bbc.co.uk": true,
42
-
"nbcnews.to": true,
43
-
"theglobeandmail.com": true,
44
-
"cnbc.com": true,
45
-
"defector.com": true,
46
-
"propublica.com": true,
47
-
"nypost.com": true,
48
-
"bloomberg.com": true,
49
-
"ctvnews.ca": true,
50
-
"thetimes.com": true,
51
-
"abc.net.au": true,
52
-
"bloom.bg": true,
53
-
"democracydocker.com": true,
54
-
"cnn.it": true,
55
-
"latimes.com": true,
56
-
"publico.es": true,
57
-
"politico.eu": true,
58
-
"kyivindependent.com": true,
59
-
"abcnews.go.com": true,
60
-
"irishtimes.com": true,
61
-
}