untappd-mcp 2.4.0 → 2.5.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +2 -2
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +74 -2
- package/dist/bundle.js +5440 -1664
- package/dist/cache/brewery-beers.js +116 -0
- package/dist/cache/favorites.js +244 -0
- package/dist/cache/multi-user.js +61 -0
- package/dist/cache/response-cache.js +23 -0
- package/dist/cache/store.js +1203 -12
- package/dist/cache/sync-beers.js +24 -2
- package/dist/cache/sync-brewery-likes.js +125 -0
- package/dist/cache/sync-venues.js +200 -0
- package/dist/cache/sync-wishlist.js +126 -0
- package/dist/cache/sync.js +61 -12
- package/dist/cache/venue-drinks.js +101 -0
- package/dist/client.js +29 -15
- package/dist/compact.js +49 -42
- package/dist/index.js +20 -3
- package/dist/menu/pick.js +7 -1
- package/dist/ratelimit.js +236 -0
- package/dist/tools/brewery.js +49 -11
- package/dist/tools/cache-optional.js +22 -0
- package/dist/tools/cache.js +457 -35
- package/dist/tools/favorites.js +110 -0
- package/dist/tools/trip.js +384 -0
- package/dist/tools/user.js +38 -7
- package/dist/tools/venue.js +92 -38
- package/dist/version.js +1 -1
- package/mint.yaml +5 -0
- package/package.json +1 -1
- package/server.json +9 -2
- package/skills/untappd-mcp/SKILL.md +30 -0
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import { mapBreweryBeerItem } from './store.js';
|
|
2
|
+
/** Default TTL of a cached brewery beer list. */
|
|
3
|
+
export const BREWERY_BEERS_TTL_MS = 7 * 24 * 60 * 60 * 1000;
|
|
4
|
+
/** Untappd's own default page size for brewery/beer_list. */
|
|
5
|
+
export const BREWERY_BEERS_DEFAULT_LIMIT = 25;
|
|
6
|
+
/** Local sort that approximates Untappd's for each sort key. */
|
|
7
|
+
function sortRows(rows, sort) {
|
|
8
|
+
const byNum = (f) => (a, b) => (f(b) ?? -Infinity) - (f(a) ?? -Infinity) || a.bid - b.bid;
|
|
9
|
+
const byStr = (f) => (a, b) => (f(a) ?? '').localeCompare(f(b) ?? '', 'en', { sensitivity: 'base' }) || a.bid - b.bid;
|
|
10
|
+
const cmp = {
|
|
11
|
+
name: byStr((r) => r.beer_name),
|
|
12
|
+
style: byStr((r) => r.style),
|
|
13
|
+
abv: byNum((r) => r.abv),
|
|
14
|
+
rating: byNum((r) => r.rating_score),
|
|
15
|
+
count: byNum((r) => r.checkin_count),
|
|
16
|
+
}[sort];
|
|
17
|
+
return [...rows].sort(cmp);
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* The requested window from a FRESH cached list, or null when the cache can't
|
|
21
|
+
* answer it faithfully: the default (popularity) order is served from the
|
|
22
|
+
* contiguous ranked prefix; any other sort only once the WHOLE list is cached
|
|
23
|
+
* (sorting a top-50 subset by name would silently be a different answer).
|
|
24
|
+
*/
|
|
25
|
+
export function serveFromList(list, req) {
|
|
26
|
+
const offset = req.offset ?? 0;
|
|
27
|
+
const limit = req.limit ?? BREWERY_BEERS_DEFAULT_LIMIT;
|
|
28
|
+
const total = list.total_count;
|
|
29
|
+
const complete = total !== null && list.beers.length >= total;
|
|
30
|
+
if (req.sort === undefined) {
|
|
31
|
+
const end = total !== null ? Math.min(offset + limit, total) : offset + limit;
|
|
32
|
+
if (list.ranked_count >= end || complete)
|
|
33
|
+
return list.beers.slice(offset, offset + limit);
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
if (!complete)
|
|
37
|
+
return null;
|
|
38
|
+
return sortRows(list.beers, req.sort).slice(offset, offset + limit);
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* A brewery's beers, from the cache when its list is fresh (TTL default 7 days)
|
|
42
|
+
* and covers the window, else from brewery/beer_list (one call) — which is then
|
|
43
|
+
* stored. A stale list, or `refresh: true`, restarts the snapshot so beers that
|
|
44
|
+
* left the list don't linger. Rows carry only global facts: beer_list's
|
|
45
|
+
* `has_had` is the AUTHENTICATED account's and is never kept or returned.
|
|
46
|
+
*/
|
|
47
|
+
export async function getBreweryBeers(client, cache, breweryId, req = {}) {
|
|
48
|
+
const now = req.now ?? new Date();
|
|
49
|
+
const ttl = req.ttlMs ?? BREWERY_BEERS_TTL_MS;
|
|
50
|
+
const expiry = (fetchedAt) => new Date(Date.parse(fetchedAt) + ttl).toISOString();
|
|
51
|
+
const cached = cache ? await cache.getBreweryBeerList(breweryId) : undefined;
|
|
52
|
+
const fresh = cached !== undefined && now.getTime() - Date.parse(cached.fetched_at) < ttl;
|
|
53
|
+
if (cached && fresh && !req.refresh) {
|
|
54
|
+
const rows = serveFromList(cached, req);
|
|
55
|
+
if (rows) {
|
|
56
|
+
return {
|
|
57
|
+
brewery_id: breweryId,
|
|
58
|
+
brewery_name: cached.brewery_name,
|
|
59
|
+
total_count: cached.total_count,
|
|
60
|
+
source: 'cache',
|
|
61
|
+
fetched_at: cached.fetched_at,
|
|
62
|
+
expires_at: expiry(cached.fetched_at),
|
|
63
|
+
beers: rows,
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
const offset = req.offset ?? 0;
|
|
68
|
+
const raw = await client.get(`/brewery/beer_list/${breweryId}`, {
|
|
69
|
+
limit: req.limit,
|
|
70
|
+
offset: req.offset,
|
|
71
|
+
sort: req.sort,
|
|
72
|
+
});
|
|
73
|
+
const items = Array.isArray(raw?.beers?.items) ? raw.beers.items : [];
|
|
74
|
+
const total = typeof raw?.total_count === 'number'
|
|
75
|
+
? raw.total_count
|
|
76
|
+
: typeof raw?.beers?.total_count === 'number'
|
|
77
|
+
? raw.beers.total_count
|
|
78
|
+
: null;
|
|
79
|
+
const stamp = now.toISOString();
|
|
80
|
+
const ranked = req.sort === undefined;
|
|
81
|
+
const rows = [];
|
|
82
|
+
let breweryName = typeof raw?.brewery?.brewery_name === 'string' ? raw.brewery.brewery_name : null;
|
|
83
|
+
items.forEach((it, i) => {
|
|
84
|
+
const r = mapBreweryBeerItem(breweryId, it, stamp, ranked ? offset + i : null);
|
|
85
|
+
if (r)
|
|
86
|
+
rows.push(r);
|
|
87
|
+
const n = it?.brewery?.brewery_name;
|
|
88
|
+
if (breweryName === null && typeof n === 'string')
|
|
89
|
+
breweryName = n;
|
|
90
|
+
});
|
|
91
|
+
let fetchedAt = null;
|
|
92
|
+
if (cache) {
|
|
93
|
+
const restart = !cached || !fresh || req.refresh === true;
|
|
94
|
+
await cache.saveBreweryBeerPage({
|
|
95
|
+
brewery_id: breweryId,
|
|
96
|
+
brewery_name: breweryName,
|
|
97
|
+
total_count: total,
|
|
98
|
+
rows,
|
|
99
|
+
ranked_offset: ranked ? offset : null,
|
|
100
|
+
item_count: items.length,
|
|
101
|
+
restart,
|
|
102
|
+
fetched_at: stamp,
|
|
103
|
+
});
|
|
104
|
+
fetchedAt = restart ? stamp : cached.fetched_at;
|
|
105
|
+
}
|
|
106
|
+
return {
|
|
107
|
+
brewery_id: breweryId,
|
|
108
|
+
brewery_name: breweryName,
|
|
109
|
+
total_count: total,
|
|
110
|
+
source: 'api',
|
|
111
|
+
fetched_at: fetchedAt,
|
|
112
|
+
expires_at: fetchedAt ? expiry(fetchedAt) : null,
|
|
113
|
+
beers: rows,
|
|
114
|
+
raw,
|
|
115
|
+
};
|
|
116
|
+
}
|
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
export const FAVORITE_SOURCES = ['untappd', 'derived', 'manual'];
|
|
2
|
+
export const DEFAULT_FAVORITE_THRESHOLDS = Object.freeze({
|
|
3
|
+
venue_min_checkins: 5,
|
|
4
|
+
venue_min_months: 3,
|
|
5
|
+
brewery_min_beers: 5,
|
|
6
|
+
brewery_min_avg_rating: 3.75,
|
|
7
|
+
brewery_min_beers_any_rating: 15,
|
|
8
|
+
beer_min_rating: 4.25,
|
|
9
|
+
beer_min_times_had: 3,
|
|
10
|
+
});
|
|
11
|
+
/** Rating a brewery with no rated beers is ranked at (a neutral mid-scale value). */
|
|
12
|
+
const UNRATED_BREWERY_RATING = 3.5;
|
|
13
|
+
export const NOT_FAVORITE = Object.freeze({ is_favorite: false, sources: [], reason: null });
|
|
14
|
+
function thresholds(t) {
|
|
15
|
+
const out = { ...DEFAULT_FAVORITE_THRESHOLDS };
|
|
16
|
+
for (const [k, v] of Object.entries(t ?? {}))
|
|
17
|
+
if (v !== undefined)
|
|
18
|
+
out[k] = v;
|
|
19
|
+
return out;
|
|
20
|
+
}
|
|
21
|
+
/** Calendar months covered from `first` to `recent`, inclusive (UTC); 0 when either is missing. */
|
|
22
|
+
export function monthSpan(first, recent) {
|
|
23
|
+
if (!first || !recent)
|
|
24
|
+
return 0;
|
|
25
|
+
const a = new Date(first);
|
|
26
|
+
const b = new Date(recent);
|
|
27
|
+
if (Number.isNaN(a.getTime()) || Number.isNaN(b.getTime()))
|
|
28
|
+
return 0;
|
|
29
|
+
const months = (b.getUTCFullYear() - a.getUTCFullYear()) * 12 + (b.getUTCMonth() - a.getUTCMonth()) + 1;
|
|
30
|
+
return Math.max(months, 1);
|
|
31
|
+
}
|
|
32
|
+
const fmt = (x) => String(Number(x.toFixed(2)));
|
|
33
|
+
const ym = (iso) => (iso ? iso.slice(0, 7) : '?');
|
|
34
|
+
function deriveVenue(v, t) {
|
|
35
|
+
const count = v.total_count ?? 0;
|
|
36
|
+
const months = monthSpan(v.first_visit, v.recent_visit);
|
|
37
|
+
const byCount = count >= t.venue_min_checkins;
|
|
38
|
+
// Only first/recent visit dates are known, so N visits can cover at most N
|
|
39
|
+
// distinct months: a 2-visit venue is never a "3 distinct months" favorite.
|
|
40
|
+
const byMonths = months >= t.venue_min_months && count >= t.venue_min_months;
|
|
41
|
+
if (!byCount && !byMonths)
|
|
42
|
+
return null;
|
|
43
|
+
const reason = byCount
|
|
44
|
+
? `${count} check-ins since ${v.first_visit ? v.first_visit.slice(0, 4) : 'unknown'}`
|
|
45
|
+
: `${count} check-ins over ${months} months (${ym(v.first_visit)} to ${ym(v.recent_visit)})`;
|
|
46
|
+
return {
|
|
47
|
+
kind: 'venue',
|
|
48
|
+
id: v.venue_id,
|
|
49
|
+
name: v.name,
|
|
50
|
+
reason,
|
|
51
|
+
score: count,
|
|
52
|
+
metrics: { total_count: count, months_spanned: months, first_visit: v.first_visit, recent_visit: v.recent_visit, city: v.city, state: v.state },
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Derived favorites of one kind for one user, best first. `ids` restricts the
|
|
57
|
+
* check to those items (for annotating results); thresholds default to the spec.
|
|
58
|
+
*/
|
|
59
|
+
export async function deriveFavorites(cache, username, kind, t, ids) {
|
|
60
|
+
const th = thresholds(t);
|
|
61
|
+
const out = [];
|
|
62
|
+
if (kind === 'venue') {
|
|
63
|
+
const rows = await cache.venueVisitStats(username, {
|
|
64
|
+
venueIds: ids,
|
|
65
|
+
minCheckins: Math.min(th.venue_min_checkins, th.venue_min_months),
|
|
66
|
+
});
|
|
67
|
+
for (const v of rows) {
|
|
68
|
+
const f = deriveVenue(v, th);
|
|
69
|
+
if (f)
|
|
70
|
+
out.push(f);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
else if (kind === 'brewery') {
|
|
74
|
+
for (const b of await cache.breweryHadStats(username, ids)) {
|
|
75
|
+
const byRating = b.beers >= th.brewery_min_beers && b.avg_rating !== null && b.avg_rating >= th.brewery_min_avg_rating;
|
|
76
|
+
const byCount = b.beers >= th.brewery_min_beers_any_rating;
|
|
77
|
+
if (!byRating && !byCount)
|
|
78
|
+
continue;
|
|
79
|
+
const avg = b.avg_rating;
|
|
80
|
+
const reason = byRating
|
|
81
|
+
? `avg ${fmt(avg)} across ${b.rated_beers === b.beers ? `${b.beers} beers` : `${b.rated_beers} rated of ${b.beers} beers`}`
|
|
82
|
+
: `${b.beers} beers had${avg !== null ? ` (avg ${fmt(avg)})` : ''}`;
|
|
83
|
+
out.push({
|
|
84
|
+
kind: 'brewery',
|
|
85
|
+
id: b.brewery_id,
|
|
86
|
+
name: b.brewery_name,
|
|
87
|
+
reason,
|
|
88
|
+
// Blend: average rating weighted by log2(beers + 1) — more beers matters, with diminishing returns.
|
|
89
|
+
score: Number(((avg ?? UNRATED_BREWERY_RATING) * Math.log2(b.beers + 1)).toFixed(3)),
|
|
90
|
+
metrics: { beers: b.beers, rated_beers: b.rated_beers, avg_rating: avg === null ? null : Number(avg.toFixed(3)) },
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
else {
|
|
95
|
+
const rows = await cache.beerHadStats(username, ids ? { bids: ids } : { minRating: th.beer_min_rating, minTimesHad: th.beer_min_times_had });
|
|
96
|
+
for (const b of rows) {
|
|
97
|
+
const byRating = b.rating !== null && b.rating >= th.beer_min_rating;
|
|
98
|
+
const byTimes = b.times_had >= th.beer_min_times_had;
|
|
99
|
+
if (!byRating && !byTimes)
|
|
100
|
+
continue;
|
|
101
|
+
const parts = [];
|
|
102
|
+
if (byRating)
|
|
103
|
+
parts.push(`rated ${fmt(b.rating)}`);
|
|
104
|
+
if (byTimes)
|
|
105
|
+
parts.push(`had ${b.times_had} times`);
|
|
106
|
+
out.push({
|
|
107
|
+
kind: 'beer',
|
|
108
|
+
id: b.bid,
|
|
109
|
+
name: b.beer_name,
|
|
110
|
+
reason: parts.join(', '),
|
|
111
|
+
score: b.rating ?? 0,
|
|
112
|
+
metrics: { rating: b.rating, times_had: b.times_had, brewery_id: b.brewery_id, brewery_name: b.brewery_name },
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
out.sort((a, b) => b.score - a.score || Number(b.metrics.times_had) - Number(a.metrics.times_had) || a.id - b.id);
|
|
116
|
+
return out;
|
|
117
|
+
}
|
|
118
|
+
out.sort((a, b) => b.score - a.score || a.id - b.id);
|
|
119
|
+
return out;
|
|
120
|
+
}
|
|
121
|
+
const manualReason = (note) => (note ? `manual: ${note}` : 'added manually');
|
|
122
|
+
const likedReason = (l) => (l.liked_at ? `liked on Untappd (since ${ym(l.liked_at)})` : 'liked on Untappd');
|
|
123
|
+
/** The user's Untappd brewery likes (the only kind Untappd has a list for). */
|
|
124
|
+
async function likesFor(cache, username, kind, ids) {
|
|
125
|
+
if (kind !== 'brewery')
|
|
126
|
+
return [];
|
|
127
|
+
return cache.getBreweryLikes(username, ids);
|
|
128
|
+
}
|
|
129
|
+
function orderSources(s) {
|
|
130
|
+
const set = new Set(s);
|
|
131
|
+
return FAVORITE_SOURCES.filter((x) => set.has(x));
|
|
132
|
+
}
|
|
133
|
+
/**
|
|
134
|
+
* Every favorite of one user (optionally one kind / one source), each listed
|
|
135
|
+
* once with merged sources: derived ones in rank order, then manual-only ones
|
|
136
|
+
* newest first, then Untappd-liked-only ones, most recently liked first.
|
|
137
|
+
*/
|
|
138
|
+
export async function listFavorites(cache, username, opts = {}) {
|
|
139
|
+
const kinds = opts.kind ? [opts.kind] : ['venue', 'brewery', 'beer'];
|
|
140
|
+
const out = [];
|
|
141
|
+
for (const kind of kinds) {
|
|
142
|
+
const manual = await cache.getManualFavorites(username, { kind });
|
|
143
|
+
const manualById = new Map(manual.map((m) => [m.id, m]));
|
|
144
|
+
const liked = await likesFor(cache, username, kind);
|
|
145
|
+
const likedById = new Map(liked.map((l) => [l.brewery_id, l]));
|
|
146
|
+
// Filtering to a source this kind has nothing in: no need to derive (it only adds merged sources).
|
|
147
|
+
const skipDerived = (opts.source === 'manual' && manual.length === 0) || (opts.source === 'untappd' && liked.length === 0);
|
|
148
|
+
const derived = skipDerived ? [] : await deriveFavorites(cache, username, kind, opts.thresholds);
|
|
149
|
+
const derivedById = new Map(derived.map((d) => [d.id, d]));
|
|
150
|
+
const entry = (id) => {
|
|
151
|
+
const d = derivedById.get(id);
|
|
152
|
+
const m = manualById.get(id);
|
|
153
|
+
const l = likedById.get(id);
|
|
154
|
+
const sources = [];
|
|
155
|
+
const reasons = [];
|
|
156
|
+
// Reasons follow FAVORITE_SOURCES order: untappd, derived, manual.
|
|
157
|
+
if (l) {
|
|
158
|
+
sources.push('untappd');
|
|
159
|
+
reasons.push(likedReason(l));
|
|
160
|
+
}
|
|
161
|
+
if (d) {
|
|
162
|
+
sources.push('derived');
|
|
163
|
+
reasons.push(d.reason);
|
|
164
|
+
}
|
|
165
|
+
if (m) {
|
|
166
|
+
sources.push('manual');
|
|
167
|
+
reasons.push(manualReason(m.note));
|
|
168
|
+
}
|
|
169
|
+
return {
|
|
170
|
+
kind,
|
|
171
|
+
id,
|
|
172
|
+
name: d?.name ?? m?.name ?? l?.brewery_name ?? null,
|
|
173
|
+
is_favorite: true,
|
|
174
|
+
sources: orderSources(sources),
|
|
175
|
+
reason: reasons.join('; '),
|
|
176
|
+
...(l ? { liked_at: l.liked_at } : {}),
|
|
177
|
+
...(m ? { note: m.note, created_at: m.created_at } : {}),
|
|
178
|
+
...(d ? { score: d.score, metrics: d.metrics } : {}),
|
|
179
|
+
};
|
|
180
|
+
};
|
|
181
|
+
const seen = new Set();
|
|
182
|
+
const ids = [...derived.map((d) => d.id), ...manual.map((m) => m.id), ...liked.map((l) => l.brewery_id)].filter((id) => {
|
|
183
|
+
if (seen.has(id))
|
|
184
|
+
return false;
|
|
185
|
+
seen.add(id);
|
|
186
|
+
return true;
|
|
187
|
+
});
|
|
188
|
+
for (const id of ids) {
|
|
189
|
+
const e = entry(id);
|
|
190
|
+
if (opts.source && !e.sources.includes(opts.source))
|
|
191
|
+
continue;
|
|
192
|
+
out.push(e);
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
return out;
|
|
196
|
+
}
|
|
197
|
+
/**
|
|
198
|
+
* Build a {@link FavoriteIndex} for the given ids, for one user or a group (a
|
|
199
|
+
* favorite of ANY member counts; with several users each reason part names its
|
|
200
|
+
* user). Cache only. Shared by every tool that carries a `favorite` field.
|
|
201
|
+
*/
|
|
202
|
+
export async function favoriteLookup(cache, usernames, ids, t) {
|
|
203
|
+
const group = usernames.length > 1;
|
|
204
|
+
const map = new Map();
|
|
205
|
+
const add = (kind, id, source, reason, user) => {
|
|
206
|
+
const k = `${kind}:${id}`;
|
|
207
|
+
const cur = map.get(k) ?? { sources: new Set(), reasons: [] };
|
|
208
|
+
cur.sources.add(source);
|
|
209
|
+
cur.reasons.push(group ? `${user}: ${reason}` : reason);
|
|
210
|
+
map.set(k, cur);
|
|
211
|
+
};
|
|
212
|
+
for (const kind of ['beer', 'brewery', 'venue']) {
|
|
213
|
+
const list = [...new Set((ids[kind] ?? []).filter((x) => typeof x === 'number' && Number.isFinite(x)))];
|
|
214
|
+
if (list.length === 0)
|
|
215
|
+
continue;
|
|
216
|
+
for (const user of usernames) {
|
|
217
|
+
const derived = await deriveFavorites(cache, user, kind, t, list);
|
|
218
|
+
const manual = await cache.getManualFavorites(user, { kind, ids: list });
|
|
219
|
+
const manualIds = new Map(manual.map((m) => [m.id, m]));
|
|
220
|
+
const liked = new Map((await likesFor(cache, user, kind, list)).map((l) => [l.brewery_id, l]));
|
|
221
|
+
// Per user, for each id: untappd, derived, then manual reason.
|
|
222
|
+
for (const id of list) {
|
|
223
|
+
const l = liked.get(id);
|
|
224
|
+
if (l)
|
|
225
|
+
add(kind, id, 'untappd', likedReason(l), user);
|
|
226
|
+
const d = derived.find((x) => x.id === id);
|
|
227
|
+
if (d)
|
|
228
|
+
add(kind, id, 'derived', d.reason, user);
|
|
229
|
+
const m = manualIds.get(id);
|
|
230
|
+
if (m)
|
|
231
|
+
add(kind, id, 'manual', manualReason(m.note), user);
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
const get = (kind, id) => {
|
|
236
|
+
if (id === null || id === undefined)
|
|
237
|
+
return { ...NOT_FAVORITE, sources: [] };
|
|
238
|
+
const hit = map.get(`${kind}:${id}`);
|
|
239
|
+
if (!hit)
|
|
240
|
+
return { ...NOT_FAVORITE, sources: [] };
|
|
241
|
+
return { is_favorite: true, sources: orderSources(hit.sources), reason: hit.reasons.join('; ') };
|
|
242
|
+
};
|
|
243
|
+
return { get, isFavorite: (kind, id) => get(kind, id).is_favorite };
|
|
244
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { createHelpfulError } from '@chrischall/mcp-utils';
|
|
2
|
+
export const MULTI_USER_MODES = ['none_had', 'any_not_had'];
|
|
3
|
+
export const DEFAULT_MULTI_USER_MODE = 'none_had';
|
|
4
|
+
/** Most usernames one group check accepts. */
|
|
5
|
+
export const MAX_GROUP_USERS = 10;
|
|
6
|
+
/**
|
|
7
|
+
* The users a cache read is about: `usernames` (deduped case-insensitively,
|
|
8
|
+
* first spelling kept) when given, else `username`, else the configured login.
|
|
9
|
+
* Passing both `username` and `usernames` is ambiguous and rejected.
|
|
10
|
+
*/
|
|
11
|
+
export function resolveUsers(username, usernames, loginName) {
|
|
12
|
+
if (usernames && usernames.length > 0) {
|
|
13
|
+
if (username !== undefined) {
|
|
14
|
+
throw createHelpfulError('Pass either username or usernames, not both.', {
|
|
15
|
+
hint: 'For a group check put every user in usernames (include yourself explicitly).',
|
|
16
|
+
});
|
|
17
|
+
}
|
|
18
|
+
const seen = new Set();
|
|
19
|
+
const out = [];
|
|
20
|
+
for (const u of usernames) {
|
|
21
|
+
const k = u.toLowerCase();
|
|
22
|
+
if (seen.has(k))
|
|
23
|
+
continue;
|
|
24
|
+
seen.add(k);
|
|
25
|
+
out.push(u);
|
|
26
|
+
}
|
|
27
|
+
return out;
|
|
28
|
+
}
|
|
29
|
+
const u = username ?? loginName;
|
|
30
|
+
if (!u) {
|
|
31
|
+
throw createHelpfulError('No username given and no configured account to fall back to.', {
|
|
32
|
+
hint: 'Pass `username` (or `usernames`), or set UNTAPPD_USERNAME so cache tools default to your own account.',
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
return [u];
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Group has-had over the cache only: one batched {@link CacheStore.hasHadMany}
|
|
39
|
+
* per user, then each bid (deduped, input order kept) is marked against `mode`.
|
|
40
|
+
*/
|
|
41
|
+
export async function multiHasHad(cache, usernames, bids, mode) {
|
|
42
|
+
const candidates = [...new Set(bids)];
|
|
43
|
+
const out = new Map();
|
|
44
|
+
for (const bid of candidates)
|
|
45
|
+
out.set(bid, { bid, qualifies: false, had_by: [], not_had_by: [] });
|
|
46
|
+
for (const user of usernames) {
|
|
47
|
+
const verdicts = await cache.hasHadMany(user, candidates);
|
|
48
|
+
for (const bid of candidates) {
|
|
49
|
+
const v = verdicts.get(bid);
|
|
50
|
+
const g = out.get(bid);
|
|
51
|
+
if (v?.had)
|
|
52
|
+
g.had_by.push({ username: user, count: v.count, last_date: v.last_date, best_rating: v.best_rating });
|
|
53
|
+
else
|
|
54
|
+
g.not_had_by.push(user);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
for (const g of out.values()) {
|
|
58
|
+
g.qualifies = mode === 'none_had' ? g.had_by.length === 0 : g.not_had_by.length > 0;
|
|
59
|
+
}
|
|
60
|
+
return out;
|
|
61
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/** Default TTL for cached venue menus and venue activity. */
|
|
2
|
+
export const VENUE_RESPONSE_TTL_MS = 6 * 60 * 60 * 1000;
|
|
3
|
+
/**
|
|
4
|
+
* Serve `key` from the short-TTL `response_cache` when fresh, else call
|
|
5
|
+
* `fetch` and store what it returns. A thrown fetch (429, budget, network) is
|
|
6
|
+
* never cached. With no cache it just fetches.
|
|
7
|
+
*/
|
|
8
|
+
export async function cachedResponse(cache, key, kind, fetch, opts = {}) {
|
|
9
|
+
const now = opts.now ?? new Date();
|
|
10
|
+
const ttl = opts.ttlMs ?? VENUE_RESPONSE_TTL_MS;
|
|
11
|
+
const expiry = (at) => new Date(Date.parse(at) + ttl).toISOString();
|
|
12
|
+
if (cache && !opts.refresh) {
|
|
13
|
+
const hit = await cache.getCachedResponse(key);
|
|
14
|
+
if (hit && now.getTime() - Date.parse(hit.fetched_at) < ttl) {
|
|
15
|
+
return { data: hit.data, hit: true, fetched_at: hit.fetched_at, expires_at: expiry(hit.fetched_at) };
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
const data = await fetch();
|
|
19
|
+
const stamp = now.toISOString();
|
|
20
|
+
if (cache)
|
|
21
|
+
await cache.putCachedResponse(key, kind, data, stamp);
|
|
22
|
+
return { data, hit: false, fetched_at: stamp, expires_at: cache ? expiry(stamp) : null };
|
|
23
|
+
}
|