this repo has no description

Compare changes

Choose any two refs to compare.

+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
··· 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
··· 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
··· 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
··· 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
··· 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 3 import 'dotenv/config' 4 4 5 5 const LABELS: Record<string, boolean> = { 6 - 'pol-link': true, 7 - 'pol-link-reply': true, 6 + 'bad-faith': true, 7 + 'off-topic': true, 8 + funny: true, 8 9 } 9 10 10 11 function run() {
+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
··· 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
··· 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 - }