···11+# Multi-Client Support
22+33+**Date:** 2026-02-13
44+**Status:** Proposed
55+66+## Problem
77+88+Tumble currently serves a single community with no tracking of where
99+posts originate. As usage expands to multiple IRC channels, Slack
1010+teams, and potentially Discord servers, posts need metadata about
1111+their client. This enables per-client duplicate detection, per-client
1212+filtering, and eventually client-scoped UI views.
1313+1414+## Solution
1515+1616+Add client metadata columns to all three content tables (links,
1717+quotes, images). Duplicate detection becomes scoped per client.
1818+API endpoints accept optional client fields on creation and support
1919+filtering by client on reads. No UI changes in this phase.
2020+2121+## Data Model
2222+2323+Five nullable columns added to `ircLink`, `quote`, and `image`:
2424+2525+| Column | Type | Purpose |
2626+|--------|------|---------|
2727+| `client_type` | VARCHAR(50) | Platform: "irc", "slack", "discord", "api", "web" |
2828+| `client_network` | VARCHAR(255) | IRC server, Slack team ID, Discord guild ID |
2929+| `client_channel` | VARCHAR(255) | IRC channel, Slack/Discord channel ID |
3030+| `client_user_id` | VARCHAR(255) | Platform-specific user ID (null for IRC) |
3131+| `client_user_name` | VARCHAR(255) | Display/mention name at time of post (null for IRC) |
3232+3333+Composite index on `(client_type, client_network, client_channel)`
3434+on each table.
3535+3636+The existing `user` column is unchanged. It continues to store the
3737+poster's name. The new `client_user_id` and `client_user_name` fields
3838+provide platform-specific identity alongside it.
3939+4040+Platform mapping:
4141+4242+| Field | IRC | Slack | Discord |
4343+|-------|-----|-------|---------|
4444+| `client_type` | "irc" | "slack" | "discord" |
4545+| `client_network` | server hostname | team ID | guild ID |
4646+| `client_channel` | channel name | channel ID | channel ID |
4747+| `client_user_id` | null | Slack user ID | Discord user ID |
4848+| `client_user_name` | null | mention name | display name |
4949+5050+GORM model additions (same for all three structs):
5151+5252+```go
5353+ClientType *string `json:"client_type,omitempty" gorm:"column:client_type"`
5454+ClientNetwork *string `json:"client_network,omitempty" gorm:"column:client_network"`
5555+ClientChannel *string `json:"client_channel,omitempty" gorm:"column:client_channel"`
5656+ClientUserID *string `json:"client_user_id,omitempty" gorm:"column:client_user_id"`
5757+ClientUserName *string `json:"client_user_name,omitempty" gorm:"column:client_user_name"`
5858+```
5959+6060+Pointer types for correct null handling and `omitempty` serialization.
6161+6262+## DDL
6363+6464+GORM AutoMigrate handles schema changes from the struct definitions.
6565+No manual DDL required. Columns are added automatically on next
6666+application startup.
6767+6868+## Data Backfill
6969+7070+All existing rows predate multi-client support and originate from IRC.
7171+A standalone SQL script (run once, manually) backfills them:
7272+7373+```sql
7474+UPDATE ircLink SET client_type = 'irc', client_network = 'jameswhite.org', client_channel = '#soggies' WHERE client_type IS NULL;
7575+UPDATE quote SET client_type = 'irc', client_network = 'jameswhite.org', client_channel = '#soggies' WHERE client_type IS NULL;
7676+UPDATE image SET client_type = 'irc', client_network = 'jameswhite.org', client_channel = '#soggies' WHERE client_type IS NULL;
7777+```
7878+7979+No backfill code in the application. No dead code paths.
8080+8181+## Duplicate Detection
8282+8383+Currently global: any POST of an existing URL returns 208. Changes to
8484+be scoped by `client_type + client_network + client_channel`.
8585+8686+**Rules:**
8787+- POST with client fields: check duplicates within that client tuple
8888+ only. Same URL from a different client is a new link (201).
8989+- POST with null/omitted client fields: fall back to global duplicate
9090+ check (backward compatibility).
9191+- Same URL within same client: 208 Already Reported with previous
9292+ submission info, as before.
9393+9494+**Example:**
9595+1. `https://example.com` from `slack / T12345 / C67890` -> 201
9696+2. Same URL from `irc / jameswhite.org / #soggies` -> 201
9797+3. Same URL from `slack / T12345 / C67890` again -> 208
9898+9999+Images: same scoping applied to MD5 deduplication.
100100+101101+Quotes: no duplicate detection currently, no change.
102102+103103+## API Changes
104104+105105+### POST Endpoints
106106+107107+`POST /api/v1/links`, `POST /api/v1/quotes` accept new optional
108108+fields:
109109+110110+```json
111111+{
112112+ "url": "https://example.com",
113113+ "user": "stahnma",
114114+ "client_type": "slack",
115115+ "client_network": "T12345",
116116+ "client_channel": "C67890",
117117+ "client_user_id": "U99999",
118118+ "client_user_name": "stahnma"
119119+}
120120+```
121121+122122+All client fields are optional. Omitting them stores nulls.
123123+124124+### GET Endpoints
125125+126126+`GET /api/v1/links`, `GET /api/v1/quotes`, `GET /api/v1/search` gain
127127+three optional query parameters:
128128+129129+- `client_type` - filter by platform
130130+- `client_network` - filter by team/server (requires client_type)
131131+- `client_channel` - filter by channel (requires client_type +
132132+ client_network)
133133+134134+Cumulative filtering. Omitting all returns everything (current
135135+behavior).
136136+137137+### Response Payloads
138138+139139+Client fields included with `omitempty`. Null client fields are
140140+omitted from responses:
141141+142142+```json
143143+{
144144+ "id": 1234,
145145+ "url": "https://example.com",
146146+ "user": "stahnma",
147147+ "title": "Example",
148148+ "client_type": "slack",
149149+ "client_network": "T12345",
150150+ "client_channel": "C67890",
151151+ "client_user_id": "U99999",
152152+ "client_user_name": "stahnma"
153153+}
154154+```
155155+156156+## Store Interface Changes
157157+158158+`GetLinkByURL` becomes client-scoped. New filter struct for query
159159+methods:
160160+161161+```go
162162+type ClientFilter struct {
163163+ ClientType *string
164164+ ClientNetwork *string
165165+ ClientChannel *string
166166+}
167167+```
168168+169169+Used by `GetLinks`, `GetQuotes`, `GetImages`, and the duplicate
170170+detection lookup. When all fields are nil, behaves identically to
171171+current implementation.
172172+173173+`TimelineItem` gains client fields for future frontend use.
174174+175175+## Testing
176176+177177+- Duplicate detection: same URL + same client = 208; same URL +
178178+ different client = 201; null client = global fallback
179179+- Filtering: client params return correct subset; params combine to
180180+ narrow results; no params returns everything
181181+- Backfill: existing rows have correct values after running script
182182+- Serialization: client fields omitted from JSON when null; present
183183+ when populated
184184+- Round-trip: POST with client fields, GET returns them; POST
185185+ without, GET omits them
186186+187187+## API Documentation
188188+189189+Update `internal/assets/openapi.json` to reflect all API changes:
190190+191191+- Add `client_type`, `client_network`, `client_channel`,
192192+ `client_user_id`, and `client_user_name` to request schemas for
193193+ `POST /api/v1/links` and `POST /api/v1/quotes`
194194+- Add `client_type`, `client_network`, `client_channel` as optional
195195+ query parameters on `GET /api/v1/links`, `GET /api/v1/quotes`, and
196196+ `GET /api/v1/search`
197197+- Add client fields to all response schemas (links, quotes, images)
198198+- Document the scoped duplicate detection behavior (208 is now
199199+ per-client when client fields are provided)
200200+201201+## Out of Scope
202202+203203+- UI changes
204204+- Authentication/authorization per client
205205+- User identity lookup table for name history tracking
206206+- Slack, Discord, or other client/bot implementations
207207+- Changes to the existing `source` request parameter (controls
208208+ response format for irc/api/html -- separate concept)
+195-2
internal/assets/openapi.json
···107107 "type": "string"
108108 },
109109 "description": "Tags associated with this link"
110110+ },
111111+ "client_type": {
112112+ "type": "string",
113113+ "description": "Client platform (e.g., irc, slack, discord, api, web)",
114114+ "example": "slack"
115115+ },
116116+ "client_network": {
117117+ "type": "string",
118118+ "description": "Client network identifier (e.g., IRC server, Slack team ID)",
119119+ "example": "T12345"
120120+ },
121121+ "client_channel": {
122122+ "type": "string",
123123+ "description": "Client channel identifier (e.g., IRC channel, Slack channel ID)",
124124+ "example": "C67890"
125125+ },
126126+ "client_user_id": {
127127+ "type": "string",
128128+ "description": "Platform-specific user ID",
129129+ "example": "U99999"
130130+ },
131131+ "client_user_name": {
132132+ "type": "string",
133133+ "description": "Display/mention name at time of post",
134134+ "example": "stahnma"
110135 }
111136 },
112137 "required": ["id", "url", "user", "created_at"],
···226251 "type": "string"
227252 },
228253 "description": "Tags associated with this quote"
254254+ },
255255+ "client_type": {
256256+ "type": "string",
257257+ "description": "Client platform (e.g., irc, slack, discord, api, web)",
258258+ "example": "slack"
259259+ },
260260+ "client_network": {
261261+ "type": "string",
262262+ "description": "Client network identifier (e.g., IRC server, Slack team ID)",
263263+ "example": "T12345"
264264+ },
265265+ "client_channel": {
266266+ "type": "string",
267267+ "description": "Client channel identifier (e.g., IRC channel, Slack channel ID)",
268268+ "example": "C67890"
269269+ },
270270+ "client_user_id": {
271271+ "type": "string",
272272+ "description": "Platform-specific user ID",
273273+ "example": "U99999"
274274+ },
275275+ "client_user_name": {
276276+ "type": "string",
277277+ "description": "Display/mention name at time of post",
278278+ "example": "stahnma"
229279 }
230280 },
231281 "required": ["id", "quote", "created_at"],
···468518 "type": "string"
469519 },
470520 "description": "Optional tags to add to the link (lowercased, no spaces)"
521521+ },
522522+ "client_type": {
523523+ "type": "string",
524524+ "description": "Client platform (e.g., irc, slack, discord, api, web)",
525525+ "example": "slack"
526526+ },
527527+ "client_network": {
528528+ "type": "string",
529529+ "description": "Client network identifier (e.g., IRC server, Slack team ID)",
530530+ "example": "T12345"
531531+ },
532532+ "client_channel": {
533533+ "type": "string",
534534+ "description": "Client channel identifier (e.g., IRC channel, Slack channel ID)",
535535+ "example": "C67890"
536536+ },
537537+ "client_user_id": {
538538+ "type": "string",
539539+ "description": "Platform-specific user ID",
540540+ "example": "U99999"
541541+ },
542542+ "client_user_name": {
543543+ "type": "string",
544544+ "description": "Display/mention name at time of post",
545545+ "example": "stahnma"
471546 }
472547 },
473548 "required": ["url", "user"],
···571646 "type": "string"
572647 },
573648 "description": "Optional tags to add to the quote (lowercased, no spaces)"
649649+ },
650650+ "client_type": {
651651+ "type": "string",
652652+ "description": "Client platform (e.g., irc, slack, discord, api, web)",
653653+ "example": "slack"
654654+ },
655655+ "client_network": {
656656+ "type": "string",
657657+ "description": "Client network identifier (e.g., IRC server, Slack team ID)",
658658+ "example": "T12345"
659659+ },
660660+ "client_channel": {
661661+ "type": "string",
662662+ "description": "Client channel identifier (e.g., IRC channel, Slack channel ID)",
663663+ "example": "C67890"
664664+ },
665665+ "client_user_id": {
666666+ "type": "string",
667667+ "description": "Platform-specific user ID",
668668+ "example": "U99999"
669669+ },
670670+ "client_user_name": {
671671+ "type": "string",
672672+ "description": "Display/mention name at time of post",
673673+ "example": "stahnma"
574674 }
575675 },
576676 "required": ["quote"],
···660760 "schema": {
661761 "type": "string"
662762 }
763763+ },
764764+ {
765765+ "name": "client_type",
766766+ "in": "query",
767767+ "required": false,
768768+ "schema": { "type": "string" },
769769+ "description": "Filter by client platform (e.g., irc, slack, discord)"
770770+ },
771771+ {
772772+ "name": "client_network",
773773+ "in": "query",
774774+ "required": false,
775775+ "schema": { "type": "string" },
776776+ "description": "Filter by client network (requires client_type). Returns 400 if client_type is not also provided."
777777+ },
778778+ {
779779+ "name": "client_channel",
780780+ "in": "query",
781781+ "required": false,
782782+ "schema": { "type": "string" },
783783+ "description": "Filter by client channel (requires client_type and client_network). Returns 400 if dependencies are missing."
663784 }
664785 ],
665786 "responses": {
···679800 }
680801 }
681802 },
803803+ "400": {
804804+ "description": "Invalid client filter parameters (e.g., client_network without client_type)",
805805+ "content": {
806806+ "application/json": {
807807+ "schema": {
808808+ "$ref": "#/components/schemas/APIError"
809809+ }
810810+ }
811811+ }
812812+ },
682813 "500": {
683814 "description": "Server error",
684815 "content": {
···693824 },
694825 "post": {
695826 "summary": "Create Link",
696696- "description": "Submits a new link. Links are always created even if the URL was previously submitted. Duplicate information is included in the response.",
827827+ "description": "Submits a new link. Links are always created even if the URL was previously submitted. Duplicate information is included in the response. When client fields (client_type, client_network, client_channel) are provided, duplicate detection is scoped per client: a URL is only considered a duplicate if it was previously submitted from the same client.",
697828 "tags": ["Links"],
698829 "requestBody": {
699830 "required": true,
···707838 },
708839 "responses": {
709840 "201": {
710710- "description": "Link created successfully",
841841+ "description": "Link created successfully. If duplicate detection finds a prior submission (scoped per client when client fields are provided), the response includes is_duplicate=true and previous_submissions.",
711842 "content": {
712843 "application/json": {
713844 "schema": {
···8821013 "default": 0,
8831014 "minimum": 0
8841015 }
10161016+ },
10171017+ {
10181018+ "name": "client_type",
10191019+ "in": "query",
10201020+ "required": false,
10211021+ "schema": { "type": "string" },
10221022+ "description": "Filter by client platform (e.g., irc, slack, discord)"
10231023+ },
10241024+ {
10251025+ "name": "client_network",
10261026+ "in": "query",
10271027+ "required": false,
10281028+ "schema": { "type": "string" },
10291029+ "description": "Filter by client network (requires client_type). Returns 400 if client_type is not also provided."
10301030+ },
10311031+ {
10321032+ "name": "client_channel",
10331033+ "in": "query",
10341034+ "required": false,
10351035+ "schema": { "type": "string" },
10361036+ "description": "Filter by client channel (requires client_type and client_network). Returns 400 if dependencies are missing."
8851037 }
8861038 ],
8871039 "responses": {
···8971049 "schema": {
8981050 "type": "string",
8991051 "description": "Plain text representation of quotes"
10521052+ }
10531053+ }
10541054+ }
10551055+ },
10561056+ "400": {
10571057+ "description": "Invalid client filter parameters (e.g., client_network without client_type)",
10581058+ "content": {
10591059+ "application/json": {
10601060+ "schema": {
10611061+ "$ref": "#/components/schemas/APIError"
9001062 }
9011063 }
9021064 }
···16881850 "default": 0,
16891851 "minimum": 0
16901852 }
18531853+ },
18541854+ {
18551855+ "name": "client_type",
18561856+ "in": "query",
18571857+ "required": false,
18581858+ "schema": { "type": "string" },
18591859+ "description": "Filter by client platform (e.g., irc, slack, discord)"
18601860+ },
18611861+ {
18621862+ "name": "client_network",
18631863+ "in": "query",
18641864+ "required": false,
18651865+ "schema": { "type": "string" },
18661866+ "description": "Filter by client network (requires client_type). Returns 400 if client_type is not also provided."
18671867+ },
18681868+ {
18691869+ "name": "client_channel",
18701870+ "in": "query",
18711871+ "required": false,
18721872+ "schema": { "type": "string" },
18731873+ "description": "Filter by client channel (requires client_type and client_network). Returns 400 if dependencies are missing."
16911874 }
16921875 ],
16931876 "responses": {
···17031886 "schema": {
17041887 "type": "string",
17051888 "description": "Plain text representation"
18891889+ }
18901890+ }
18911891+ }
18921892+ },
18931893+ "400": {
18941894+ "description": "Invalid client filter parameters (e.g., client_network without client_type)",
18951895+ "content": {
18961896+ "application/json": {
18971897+ "schema": {
18981898+ "$ref": "#/components/schemas/APIError"
17061899 }
17071900 }
17081901 }
···44444545// OEmbedResponse represents standard OEmbed keys
4646type OEmbedResponse struct {
4747- Type string `json:"type"`
4747+ Type string `json:"type"`
4848 Version interface{} `json:"version"`
4949- Title string `json:"title"`
5050- AuthorName string `json:"author_name"`
5151- AuthorURL string `json:"author_url"`
5252- ProviderName string `json:"provider_name"`
5353- ProviderURL string `json:"provider_url"`
4949+ Title string `json:"title"`
5050+ AuthorName string `json:"author_name"`
5151+ AuthorURL string `json:"author_url"`
5252+ ProviderName string `json:"provider_name"`
5353+ ProviderURL string `json:"provider_url"`
54545555 // CacheAge int64 `json:"cache_age"`
5656 ThumbnailURL string `json:"thumbnail_url"`
···9797 // For error entries, look up the link's timestamp to determine TTL tier
9898 var linkTimestamp time.Time
9999 if strings.Contains(string(cached.Data), `"error":`) {
100100- if links, err := h.Store.GetIRCLinksByURL(r.Context(), urlParam); err == nil && len(links) > 0 {
100100+ if links, err := h.Store.GetIRCLinksByURL(r.Context(), urlParam, data.ClientFilter{}); err == nil && len(links) > 0 {
101101 linkTimestamp = links[len(links)-1].Timestamp // oldest link (results ordered DESC)
102102 }
103103 }
+8-2
internal/handler/preview_youtube.go
···3434 title = strings.TrimSuffix(title, " - YouTube")
3535 meta["title"] = title
36363737- // If scrape yielded no real title or image, fall through to OEmbed
3737+ // Empty title after stripping suffix means the page had no real video title
3838+ // (e.g. " - YouTube"), which is another form of YouTube's soft 404.
3939+ if title == "" {
4040+ return nil, fmt.Errorf("status 404")
4141+ }
4242+4343+ // If scrape yielded no image, fall through to OEmbed
3844 // which is more reliable for YouTube
3939- if title == "" || meta["image"] == "" {
4545+ if meta["image"] == "" {
4046 return nil, fmt.Errorf("incomplete scrape, falling back to oembed")
4147 }
4248
···11+-- One-time backfill: set client metadata on all existing rows.
22+-- All existing data originates from IRC, #soggies channel on jameswhite.org.
33+-- Run this manually after deploying the client fields migration.
44+--
55+-- Usage (SQLite): sqlite3 tumble.db < sql/backfill_clients.sql
66+-- Usage (MySQL): mysql -u user -p tumble < sql/backfill_clients.sql
77+88+UPDATE ircLink
99+SET client_type = 'irc',
1010+ client_network = 'jameswhite.org',
1111+ client_channel = '#soggies'
1212+WHERE client_type IS NULL;
1313+1414+UPDATE quote
1515+SET client_type = 'irc',
1616+ client_network = 'jameswhite.org',
1717+ client_channel = '#soggies'
1818+WHERE client_type IS NULL;
1919+2020+UPDATE image
2121+SET client_type = 'irc',
2222+ client_network = 'jameswhite.org',
2323+ client_channel = '#soggies'
2424+WHERE client_type IS NULL;
+3-3
tests/api_test.sh
···7878 -H "Content-Type: application/json" \
7979 -H "Accept: text/plain" \
8080 -d '{"user":"testdel","url":"http://delete-test.com"}')
8181-# Check if we got an ID (numeric)
8282-if [[ "$CREATE_OUT" =~ ^[0-9]+$ ]]; then
8383- DEL_ID=$CREATE_OUT
8181+# Parse ID from response (format: "Created link N: URL")
8282+if [[ "$CREATE_OUT" =~ Created\ link\ ([0-9]+) ]]; then
8383+ DEL_ID=${BASH_REMATCH[1]}
8484 # Delete it
8585 DEL_STATUS=$(curl -s -o /dev/null -w "%{http_code}" -X DELETE -H "X-API-Key: test-admin-secret" "$BASE_URL/api/v1/links/$DEL_ID")
8686 if [ "$DEL_STATUS" == "204" ]; then
+3-2
tests/preview_test.sh
···33# Usage: ./tests/preview_test.sh [BASE_URL]
4455BASE_URL="${1:-http://localhost:8080}"
66-ENDPOINT="$BASE_URL/ogpreview"
66+ENDPOINT="$BASE_URL/api/v1/preview"
7788echo "Running Preview Tests against $ENDPOINT"
99···5050 '.provider_name == "Reddit" or .title != null'
51515252# Invalid: specific non-existent post.
5353+# Reddit may return an error, a generic title, or a minimal response with just provider_name.
5354test_preview "Reddit Invalid" \
5455 "https://www.reddit.com/r/valheim/comments/INVALID_ID_12345/" \
5555- '.error != null or (.title | contains("Page not found") or contains("Reddit"))'
5656+ '.error != null or .title == null or (.title | contains("Page not found") or contains("Reddit"))'
56575758# --- SPOTIFY ---
5859# Valid