@udondan/duolingo 1.0.0
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/LICENSE +21 -0
- package/README.md +373 -0
- package/dist/client/duolingo.d.ts +170 -0
- package/dist/client/duolingo.d.ts.map +1 -0
- package/dist/client/duolingo.js +499 -0
- package/dist/client/duolingo.js.map +1 -0
- package/dist/client/errors.d.ts +24 -0
- package/dist/client/errors.d.ts.map +1 -0
- package/dist/client/errors.js +41 -0
- package/dist/client/errors.js.map +1 -0
- package/dist/client/types.d.ts +272 -0
- package/dist/client/types.d.ts.map +1 -0
- package/dist/client/types.js +6 -0
- package/dist/client/types.js.map +1 -0
- package/dist/index.d.ts +14 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +15 -0
- package/dist/index.js.map +1 -0
- package/dist/server.d.ts +19 -0
- package/dist/server.d.ts.map +1 -0
- package/dist/server.js +40 -0
- package/dist/server.js.map +1 -0
- package/dist/tools/account.d.ts +10 -0
- package/dist/tools/account.d.ts.map +1 -0
- package/dist/tools/account.js +746 -0
- package/dist/tools/account.js.map +1 -0
- package/dist/tools/helpers.d.ts +18 -0
- package/dist/tools/helpers.d.ts.map +1 -0
- package/dist/tools/helpers.js +83 -0
- package/dist/tools/helpers.js.map +1 -0
- package/dist/tools/language.d.ts +11 -0
- package/dist/tools/language.d.ts.map +1 -0
- package/dist/tools/language.js +611 -0
- package/dist/tools/language.js.map +1 -0
- package/dist/tools/shop.d.ts +8 -0
- package/dist/tools/shop.d.ts.map +1 -0
- package/dist/tools/shop.js +125 -0
- package/dist/tools/shop.js.map +1 -0
- package/package.json +85 -0
|
@@ -0,0 +1,746 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Account-level Duolingo MCP tools.
|
|
3
|
+
*
|
|
4
|
+
* Tools: get_user_info, get_settings, get_streak_info, get_daily_xp_progress,
|
|
5
|
+
* get_languages, get_courses, get_friends, get_calendar, get_leaderboard,
|
|
6
|
+
* get_shop_items, get_health, get_currencies, get_streak_goal
|
|
7
|
+
*/
|
|
8
|
+
import { z } from 'zod';
|
|
9
|
+
import { getClient } from '../client/duolingo.js';
|
|
10
|
+
import { handleError, ResponseFormatSchema, UsernameFieldSchema, } from './helpers.js';
|
|
11
|
+
export function registerAccountTools(server) {
|
|
12
|
+
// -------------------------------------------------------------------------
|
|
13
|
+
// Get User Info
|
|
14
|
+
// -------------------------------------------------------------------------
|
|
15
|
+
server.registerTool('duolingo_get_user_info', {
|
|
16
|
+
title: 'Get Duolingo User Info',
|
|
17
|
+
description: "Get a Duolingo user's profile information. Returns username, full name, bio, location, " +
|
|
18
|
+
'avatar URL, follower/following counts, learning language, UI language, cohort, admin status, and more.',
|
|
19
|
+
inputSchema: {
|
|
20
|
+
username: UsernameFieldSchema,
|
|
21
|
+
response_format: ResponseFormatSchema,
|
|
22
|
+
},
|
|
23
|
+
annotations: {
|
|
24
|
+
readOnlyHint: true,
|
|
25
|
+
destructiveHint: false,
|
|
26
|
+
idempotentHint: true,
|
|
27
|
+
openWorldHint: true,
|
|
28
|
+
},
|
|
29
|
+
}, async ({ username, response_format }) => {
|
|
30
|
+
try {
|
|
31
|
+
const userData = await getClient().getUserData(username);
|
|
32
|
+
// num_followers/num_following moved to tracking_properties in current API
|
|
33
|
+
const tp = userData.tracking_properties ?? {};
|
|
34
|
+
const numFollowers = typeof tp.num_followers === 'number'
|
|
35
|
+
? tp.num_followers
|
|
36
|
+
: userData.num_followers;
|
|
37
|
+
const numFollowing = typeof tp.num_following === 'number'
|
|
38
|
+
? tp.num_following
|
|
39
|
+
: userData.num_following;
|
|
40
|
+
const info = {
|
|
41
|
+
username: userData.username,
|
|
42
|
+
fullname: userData.fullname,
|
|
43
|
+
bio: userData.bio,
|
|
44
|
+
location: userData.location,
|
|
45
|
+
avatar: userData.avatar,
|
|
46
|
+
id: userData.id,
|
|
47
|
+
num_followers: numFollowers,
|
|
48
|
+
num_following: numFollowing,
|
|
49
|
+
learning_language_string: userData.learning_language_string,
|
|
50
|
+
ui_language: userData.ui_language,
|
|
51
|
+
admin: userData.admin,
|
|
52
|
+
cohort: userData.cohort,
|
|
53
|
+
// creation_date is an ISO string (preferred); created is a human-readable relative string
|
|
54
|
+
creation_date: userData.creation_date,
|
|
55
|
+
created: userData.created,
|
|
56
|
+
};
|
|
57
|
+
if (response_format === 'json') {
|
|
58
|
+
return {
|
|
59
|
+
content: [{ type: 'text', text: JSON.stringify(info, null, 2) }],
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
const lines = [`# Duolingo User: ${info.username}`, ''];
|
|
63
|
+
if (info.fullname.length > 0)
|
|
64
|
+
lines.push(`- **Full Name**: ${info.fullname}`);
|
|
65
|
+
if (info.bio.length > 0)
|
|
66
|
+
lines.push(`- **Bio**: ${info.bio}`);
|
|
67
|
+
if (info.location !== null && info.location.length > 0)
|
|
68
|
+
lines.push(`- **Location**: ${info.location}`);
|
|
69
|
+
lines.push(`- **Learning**: ${info.learning_language_string.length > 0 ? info.learning_language_string : 'N/A'}`);
|
|
70
|
+
lines.push(`- **UI Language**: ${info.ui_language.length > 0 ? info.ui_language : 'N/A'}`);
|
|
71
|
+
if (typeof info.num_followers === 'number')
|
|
72
|
+
lines.push(`- **Followers**: ${info.num_followers}`);
|
|
73
|
+
if (typeof info.num_following === 'number')
|
|
74
|
+
lines.push(`- **Following**: ${info.num_following}`);
|
|
75
|
+
if (info.creation_date)
|
|
76
|
+
lines.push(`- **Member Since**: ${info.creation_date.slice(0, 10)}`);
|
|
77
|
+
else if (info.created)
|
|
78
|
+
lines.push(`- **Member Since**: ${info.created}`);
|
|
79
|
+
if (info.avatar.length > 0)
|
|
80
|
+
lines.push(`- **Avatar**: ${info.avatar}`);
|
|
81
|
+
return { content: [{ type: 'text', text: lines.join('\n') }] };
|
|
82
|
+
}
|
|
83
|
+
catch (err) {
|
|
84
|
+
return { content: [{ type: 'text', text: handleError(err) }] };
|
|
85
|
+
}
|
|
86
|
+
});
|
|
87
|
+
// -------------------------------------------------------------------------
|
|
88
|
+
// Get Settings (authenticated user only)
|
|
89
|
+
// -------------------------------------------------------------------------
|
|
90
|
+
server.registerTool('duolingo_get_settings', {
|
|
91
|
+
title: 'Get Duolingo User Settings',
|
|
92
|
+
description: "Get the authenticated user's Duolingo account settings. " +
|
|
93
|
+
'Returns notification preferences and follow/follower relationship flags. ' +
|
|
94
|
+
'Only works for the authenticated user.',
|
|
95
|
+
inputSchema: {
|
|
96
|
+
response_format: ResponseFormatSchema,
|
|
97
|
+
},
|
|
98
|
+
annotations: {
|
|
99
|
+
readOnlyHint: true,
|
|
100
|
+
destructiveHint: false,
|
|
101
|
+
idempotentHint: true,
|
|
102
|
+
openWorldHint: true,
|
|
103
|
+
},
|
|
104
|
+
}, async ({ response_format }) => {
|
|
105
|
+
try {
|
|
106
|
+
const userData = await getClient().getUserData();
|
|
107
|
+
// is_follower_by / is_following are no longer in the API response;
|
|
108
|
+
// notify_comment and deactivated are still present.
|
|
109
|
+
const settings = {
|
|
110
|
+
notify_comment: userData.notify_comment,
|
|
111
|
+
deactivated: userData.deactivated,
|
|
112
|
+
};
|
|
113
|
+
// Include social flags only when the API returns them
|
|
114
|
+
if (userData.is_follower_by !== undefined)
|
|
115
|
+
settings.is_follower_by = userData.is_follower_by;
|
|
116
|
+
if (userData.is_following !== undefined)
|
|
117
|
+
settings.is_following = userData.is_following;
|
|
118
|
+
if (response_format === 'json') {
|
|
119
|
+
return {
|
|
120
|
+
content: [
|
|
121
|
+
{ type: 'text', text: JSON.stringify(settings, null, 2) },
|
|
122
|
+
],
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
const lines = ['# Duolingo Settings', ''];
|
|
126
|
+
for (const [key, value] of Object.entries(settings)) {
|
|
127
|
+
const label = key
|
|
128
|
+
.replace(/_/g, ' ')
|
|
129
|
+
.replace(/\b\w/g, (c) => c.toUpperCase());
|
|
130
|
+
lines.push(`- **${label}**: ${String(value)}`);
|
|
131
|
+
}
|
|
132
|
+
return { content: [{ type: 'text', text: lines.join('\n') }] };
|
|
133
|
+
}
|
|
134
|
+
catch (err) {
|
|
135
|
+
return { content: [{ type: 'text', text: handleError(err) }] };
|
|
136
|
+
}
|
|
137
|
+
});
|
|
138
|
+
// -------------------------------------------------------------------------
|
|
139
|
+
// Get Streak Info
|
|
140
|
+
// -------------------------------------------------------------------------
|
|
141
|
+
server.registerTool('duolingo_get_streak_info', {
|
|
142
|
+
title: 'Get Duolingo Streak Info',
|
|
143
|
+
description: "Get a Duolingo user's current streak information. " +
|
|
144
|
+
'Returns the site-wide streak count, daily XP goal, and whether the streak has been extended today.',
|
|
145
|
+
inputSchema: {
|
|
146
|
+
username: UsernameFieldSchema,
|
|
147
|
+
response_format: ResponseFormatSchema,
|
|
148
|
+
},
|
|
149
|
+
annotations: {
|
|
150
|
+
readOnlyHint: true,
|
|
151
|
+
destructiveHint: false,
|
|
152
|
+
idempotentHint: true,
|
|
153
|
+
openWorldHint: true,
|
|
154
|
+
},
|
|
155
|
+
}, async ({ username, response_format }) => {
|
|
156
|
+
try {
|
|
157
|
+
const client = getClient();
|
|
158
|
+
// Fetch legacy user data (has daily_goal) and v2 data (has streak details)
|
|
159
|
+
let userId;
|
|
160
|
+
let dailyGoal;
|
|
161
|
+
if (!username) {
|
|
162
|
+
const userData = await client.getUserData();
|
|
163
|
+
userId = userData.id;
|
|
164
|
+
dailyGoal = userData.daily_goal ?? null;
|
|
165
|
+
}
|
|
166
|
+
else {
|
|
167
|
+
userId = await client.getUserIdByUsername(username);
|
|
168
|
+
const userData = await client.getUserData(username);
|
|
169
|
+
dailyGoal = userData.daily_goal ?? null;
|
|
170
|
+
}
|
|
171
|
+
const v2 = await client.getUserDataV2(userId);
|
|
172
|
+
const streakData = v2.streakData;
|
|
173
|
+
const current = streakData.currentStreak;
|
|
174
|
+
const info = {
|
|
175
|
+
site_streak: v2.streak,
|
|
176
|
+
daily_goal: dailyGoal,
|
|
177
|
+
streak_extended_today: current !== null
|
|
178
|
+
? current.lastExtendedDate ===
|
|
179
|
+
new Date().toISOString().slice(0, 10)
|
|
180
|
+
: false,
|
|
181
|
+
streak_start: current?.startDate ?? null,
|
|
182
|
+
longest_streak: streakData.longestStreak?.length ?? null,
|
|
183
|
+
};
|
|
184
|
+
if (response_format === 'json') {
|
|
185
|
+
return {
|
|
186
|
+
content: [{ type: 'text', text: JSON.stringify(info, null, 2) }],
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
const extended = info.streak_extended_today ? '✅ Yes' : '❌ No';
|
|
190
|
+
const lines = [
|
|
191
|
+
'# Duolingo Streak',
|
|
192
|
+
'',
|
|
193
|
+
`- **Current Streak**: ${info.site_streak} days`,
|
|
194
|
+
`- **Extended Today**: ${extended}`,
|
|
195
|
+
];
|
|
196
|
+
if (typeof info.daily_goal === 'number')
|
|
197
|
+
lines.push(`- **Daily Goal**: ${info.daily_goal} XP`);
|
|
198
|
+
if (info.streak_start !== null)
|
|
199
|
+
lines.push(`- **Streak Started**: ${info.streak_start}`);
|
|
200
|
+
if (info.longest_streak !== null)
|
|
201
|
+
lines.push(`- **Longest Streak**: ${info.longest_streak} days`);
|
|
202
|
+
return { content: [{ type: 'text', text: lines.join('\n') }] };
|
|
203
|
+
}
|
|
204
|
+
catch (err) {
|
|
205
|
+
return { content: [{ type: 'text', text: handleError(err) }] };
|
|
206
|
+
}
|
|
207
|
+
});
|
|
208
|
+
// -------------------------------------------------------------------------
|
|
209
|
+
// Get Daily XP Progress (authenticated user only)
|
|
210
|
+
// -------------------------------------------------------------------------
|
|
211
|
+
server.registerTool('duolingo_get_daily_xp_progress', {
|
|
212
|
+
title: 'Get Duolingo Daily XP Progress',
|
|
213
|
+
description: "Get the authenticated user's XP progress for today. " +
|
|
214
|
+
'Returns the daily XP goal, total XP earned today, and a list of lessons completed today. ' +
|
|
215
|
+
'Only works for the authenticated user.',
|
|
216
|
+
inputSchema: {
|
|
217
|
+
response_format: ResponseFormatSchema,
|
|
218
|
+
},
|
|
219
|
+
annotations: {
|
|
220
|
+
readOnlyHint: true,
|
|
221
|
+
destructiveHint: false,
|
|
222
|
+
idempotentHint: false,
|
|
223
|
+
openWorldHint: true,
|
|
224
|
+
},
|
|
225
|
+
}, async ({ response_format }) => {
|
|
226
|
+
try {
|
|
227
|
+
const client = getClient();
|
|
228
|
+
const userData = await client.getUserData();
|
|
229
|
+
const dailyData = await client.getUserDataById(userData.id, [
|
|
230
|
+
'xpGoal',
|
|
231
|
+
'xpGains',
|
|
232
|
+
'streakData',
|
|
233
|
+
]);
|
|
234
|
+
// Filter lessons to only those from today.
|
|
235
|
+
// Use streakData.updatedTimestamp as the "last midnight" reference.
|
|
236
|
+
const reportedTimestamp = dailyData.streakData.updatedTimestamp;
|
|
237
|
+
const reportedMidnight = new Date(reportedTimestamp * 1000);
|
|
238
|
+
reportedMidnight.setHours(0, 0, 0, 0);
|
|
239
|
+
const systemMidnight = new Date();
|
|
240
|
+
systemMidnight.setHours(0, 0, 0, 0);
|
|
241
|
+
// If reported midnight is in the future, fall back to system midnight
|
|
242
|
+
const cutoffMidnight = reportedMidnight > systemMidnight ? systemMidnight : reportedMidnight;
|
|
243
|
+
const updateCutoff = Math.round(cutoffMidnight.getTime() / 1000);
|
|
244
|
+
const lessonsToday = dailyData.xpGains.filter((lesson) => lesson.time > updateCutoff);
|
|
245
|
+
const xpToday = lessonsToday.reduce((sum, l) => sum + l.xp, 0);
|
|
246
|
+
const progress = {
|
|
247
|
+
xp_goal: dailyData.xpGoal,
|
|
248
|
+
xp_today: xpToday,
|
|
249
|
+
lessons_today: lessonsToday,
|
|
250
|
+
};
|
|
251
|
+
if (response_format === 'json') {
|
|
252
|
+
return {
|
|
253
|
+
content: [
|
|
254
|
+
{ type: 'text', text: JSON.stringify(progress, null, 2) },
|
|
255
|
+
],
|
|
256
|
+
};
|
|
257
|
+
}
|
|
258
|
+
const pct = progress.xp_goal > 0
|
|
259
|
+
? Math.round((xpToday / progress.xp_goal) * 100)
|
|
260
|
+
: 0;
|
|
261
|
+
const lines = [
|
|
262
|
+
'# Daily XP Progress',
|
|
263
|
+
'',
|
|
264
|
+
`- **XP Today**: ${xpToday} / ${progress.xp_goal} (${pct}%)`,
|
|
265
|
+
`- **Lessons Completed**: ${lessonsToday.length}`,
|
|
266
|
+
];
|
|
267
|
+
return { content: [{ type: 'text', text: lines.join('\n') }] };
|
|
268
|
+
}
|
|
269
|
+
catch (err) {
|
|
270
|
+
return { content: [{ type: 'text', text: handleError(err) }] };
|
|
271
|
+
}
|
|
272
|
+
});
|
|
273
|
+
// -------------------------------------------------------------------------
|
|
274
|
+
// Get Languages
|
|
275
|
+
// -------------------------------------------------------------------------
|
|
276
|
+
server.registerTool('duolingo_get_languages', {
|
|
277
|
+
title: 'Get Duolingo Learning Languages',
|
|
278
|
+
description: 'Get the list of languages a Duolingo user is currently learning.',
|
|
279
|
+
inputSchema: {
|
|
280
|
+
username: UsernameFieldSchema,
|
|
281
|
+
abbreviations: z
|
|
282
|
+
.boolean()
|
|
283
|
+
.default(false)
|
|
284
|
+
.describe("If true, return language abbreviations (e.g. 'fr') instead of full names (e.g. 'French')."),
|
|
285
|
+
response_format: ResponseFormatSchema,
|
|
286
|
+
},
|
|
287
|
+
annotations: {
|
|
288
|
+
readOnlyHint: true,
|
|
289
|
+
destructiveHint: false,
|
|
290
|
+
idempotentHint: true,
|
|
291
|
+
openWorldHint: true,
|
|
292
|
+
},
|
|
293
|
+
}, async ({ username, abbreviations, response_format }) => {
|
|
294
|
+
try {
|
|
295
|
+
const client = getClient();
|
|
296
|
+
// Resolve user ID for the v2 API
|
|
297
|
+
let userId;
|
|
298
|
+
if (!username) {
|
|
299
|
+
const userData = await client.getUserData();
|
|
300
|
+
userId = userData.id;
|
|
301
|
+
}
|
|
302
|
+
else {
|
|
303
|
+
userId = await client.getUserIdByUsername(username);
|
|
304
|
+
}
|
|
305
|
+
const v2 = await client.getUserDataV2(userId);
|
|
306
|
+
// Filter to language courses only (not math/chess/music)
|
|
307
|
+
const langCourses = v2.courses.filter((c) => c.subject === 'language' && c.learningLanguage !== undefined);
|
|
308
|
+
const languages = langCourses.map((c) => abbreviations
|
|
309
|
+
? (c.learningLanguage ?? c.topic)
|
|
310
|
+
: (c.title ?? c.learningLanguage ?? c.topic));
|
|
311
|
+
if (languages.length === 0) {
|
|
312
|
+
return {
|
|
313
|
+
content: [
|
|
314
|
+
{
|
|
315
|
+
type: 'text',
|
|
316
|
+
text: 'No languages found. The user may not be learning any languages.',
|
|
317
|
+
},
|
|
318
|
+
],
|
|
319
|
+
};
|
|
320
|
+
}
|
|
321
|
+
if (response_format === 'json') {
|
|
322
|
+
return {
|
|
323
|
+
content: [
|
|
324
|
+
{ type: 'text', text: JSON.stringify(languages, null, 2) },
|
|
325
|
+
],
|
|
326
|
+
};
|
|
327
|
+
}
|
|
328
|
+
const lines = ['# Learning Languages', ''];
|
|
329
|
+
for (const lang of languages) {
|
|
330
|
+
lines.push(`- ${lang}`);
|
|
331
|
+
}
|
|
332
|
+
return { content: [{ type: 'text', text: lines.join('\n') }] };
|
|
333
|
+
}
|
|
334
|
+
catch (err) {
|
|
335
|
+
return { content: [{ type: 'text', text: handleError(err) }] };
|
|
336
|
+
}
|
|
337
|
+
});
|
|
338
|
+
// -------------------------------------------------------------------------
|
|
339
|
+
// Get Friends
|
|
340
|
+
// -------------------------------------------------------------------------
|
|
341
|
+
server.registerTool('duolingo_get_friends', {
|
|
342
|
+
title: 'Get Duolingo Friends',
|
|
343
|
+
description: 'Get the list of Duolingo users that a given user is following (their friends). ' +
|
|
344
|
+
"Returns each friend's username, display name, and total XP. " +
|
|
345
|
+
'Only works for the authenticated user.',
|
|
346
|
+
inputSchema: {
|
|
347
|
+
response_format: ResponseFormatSchema,
|
|
348
|
+
},
|
|
349
|
+
annotations: {
|
|
350
|
+
readOnlyHint: true,
|
|
351
|
+
destructiveHint: false,
|
|
352
|
+
idempotentHint: true,
|
|
353
|
+
openWorldHint: true,
|
|
354
|
+
},
|
|
355
|
+
}, async ({ response_format }) => {
|
|
356
|
+
try {
|
|
357
|
+
const client = getClient();
|
|
358
|
+
const userData = await client.getUserData();
|
|
359
|
+
// Friends = people the authenticated user is following
|
|
360
|
+
const following = await client.getFollowing(userData.id);
|
|
361
|
+
if (following.length === 0) {
|
|
362
|
+
return { content: [{ type: 'text', text: 'No friends found.' }] };
|
|
363
|
+
}
|
|
364
|
+
const friends = following.map((f) => ({
|
|
365
|
+
username: f.username,
|
|
366
|
+
id: f.userId,
|
|
367
|
+
points: f.totalXp,
|
|
368
|
+
display_name: f.displayName,
|
|
369
|
+
}));
|
|
370
|
+
if (response_format === 'json') {
|
|
371
|
+
return {
|
|
372
|
+
content: [{ type: 'text', text: JSON.stringify(friends, null, 2) }],
|
|
373
|
+
};
|
|
374
|
+
}
|
|
375
|
+
const lines = ['# Duolingo Friends', ''];
|
|
376
|
+
for (const friend of friends) {
|
|
377
|
+
const name = friend.display_name ?? friend.username;
|
|
378
|
+
lines.push(`- **${name}** (@${friend.username}) — ${friend.points} XP`);
|
|
379
|
+
}
|
|
380
|
+
return { content: [{ type: 'text', text: lines.join('\n') }] };
|
|
381
|
+
}
|
|
382
|
+
catch (err) {
|
|
383
|
+
return { content: [{ type: 'text', text: handleError(err) }] };
|
|
384
|
+
}
|
|
385
|
+
});
|
|
386
|
+
// -------------------------------------------------------------------------
|
|
387
|
+
// Get Calendar
|
|
388
|
+
// -------------------------------------------------------------------------
|
|
389
|
+
server.registerTool('duolingo_get_calendar', {
|
|
390
|
+
title: 'Get Duolingo Activity Calendar',
|
|
391
|
+
description: "Get a Duolingo user's recent activity calendar. " +
|
|
392
|
+
'Returns all recent activity entries, sorted newest first. ' +
|
|
393
|
+
'The Duolingo API provides roughly the last 2 weeks of activity. ' +
|
|
394
|
+
'Note: the calendar reflects the currently selected course only ' +
|
|
395
|
+
'(e.g. Spanish, Math, Chess) — not all courses combined.',
|
|
396
|
+
inputSchema: {
|
|
397
|
+
username: UsernameFieldSchema,
|
|
398
|
+
response_format: ResponseFormatSchema,
|
|
399
|
+
},
|
|
400
|
+
annotations: {
|
|
401
|
+
readOnlyHint: true,
|
|
402
|
+
destructiveHint: false,
|
|
403
|
+
idempotentHint: true,
|
|
404
|
+
openWorldHint: true,
|
|
405
|
+
},
|
|
406
|
+
}, async ({ username, response_format }) => {
|
|
407
|
+
try {
|
|
408
|
+
const userData = await getClient().getUserData(username);
|
|
409
|
+
const calendar = userData.calendar;
|
|
410
|
+
// Sort newest first
|
|
411
|
+
const sorted = [...calendar].sort((a, b) => b.datetime - a.datetime);
|
|
412
|
+
if (sorted.length === 0) {
|
|
413
|
+
return {
|
|
414
|
+
content: [{ type: 'text', text: 'No calendar entries found.' }],
|
|
415
|
+
};
|
|
416
|
+
}
|
|
417
|
+
if (response_format === 'json') {
|
|
418
|
+
return {
|
|
419
|
+
content: [{ type: 'text', text: JSON.stringify(sorted, null, 2) }],
|
|
420
|
+
};
|
|
421
|
+
}
|
|
422
|
+
const lines = ['# Activity Calendar', ''];
|
|
423
|
+
for (const entry of sorted) {
|
|
424
|
+
const date = new Date(entry.datetime)
|
|
425
|
+
.toISOString()
|
|
426
|
+
.replace('T', ' ')
|
|
427
|
+
.slice(0, 16);
|
|
428
|
+
const parts = [`**${date}**`, `${entry.improvement} XP`];
|
|
429
|
+
if (entry.skill_id)
|
|
430
|
+
parts.push(`skill: ${entry.skill_id}`);
|
|
431
|
+
if (entry.event_type)
|
|
432
|
+
parts.push(`type: ${entry.event_type}`);
|
|
433
|
+
lines.push(`- ${parts.join(' — ')}`);
|
|
434
|
+
}
|
|
435
|
+
return { content: [{ type: 'text', text: lines.join('\n') }] };
|
|
436
|
+
}
|
|
437
|
+
catch (err) {
|
|
438
|
+
return { content: [{ type: 'text', text: handleError(err) }] };
|
|
439
|
+
}
|
|
440
|
+
});
|
|
441
|
+
// -------------------------------------------------------------------------
|
|
442
|
+
// Get Leaderboard
|
|
443
|
+
// -------------------------------------------------------------------------
|
|
444
|
+
server.registerTool('duolingo_get_leaderboard', {
|
|
445
|
+
title: 'Get Duolingo Leaderboard',
|
|
446
|
+
description: "Get the XP leaderboard for a Duolingo user's friends. " +
|
|
447
|
+
'Returns the users they follow, sorted by XP for the given time unit (week or month). ' +
|
|
448
|
+
'Only works for the authenticated user.',
|
|
449
|
+
inputSchema: {
|
|
450
|
+
unit: z
|
|
451
|
+
.enum(['week', 'month'])
|
|
452
|
+
.default('week')
|
|
453
|
+
.describe("Time unit for the leaderboard: 'week' or 'month'."),
|
|
454
|
+
response_format: ResponseFormatSchema,
|
|
455
|
+
},
|
|
456
|
+
annotations: {
|
|
457
|
+
readOnlyHint: true,
|
|
458
|
+
destructiveHint: false,
|
|
459
|
+
idempotentHint: false,
|
|
460
|
+
openWorldHint: true,
|
|
461
|
+
},
|
|
462
|
+
}, async ({ unit, response_format }) => {
|
|
463
|
+
try {
|
|
464
|
+
const client = getClient();
|
|
465
|
+
const userData = await client.getUserData();
|
|
466
|
+
// Leaderboard = people the authenticated user is following, sorted by XP
|
|
467
|
+
const following = await client.getFollowing(userData.id);
|
|
468
|
+
if (following.length === 0) {
|
|
469
|
+
return {
|
|
470
|
+
content: [
|
|
471
|
+
{
|
|
472
|
+
type: 'text',
|
|
473
|
+
text: `No leaderboard data found for unit '${unit}'.`,
|
|
474
|
+
},
|
|
475
|
+
],
|
|
476
|
+
};
|
|
477
|
+
}
|
|
478
|
+
const data = following
|
|
479
|
+
.map((f) => ({
|
|
480
|
+
unit,
|
|
481
|
+
id: f.userId,
|
|
482
|
+
username: f.username,
|
|
483
|
+
display_name: f.displayName,
|
|
484
|
+
points: unit === 'week' ? (f.userScore?.score ?? 0) : f.totalXp,
|
|
485
|
+
}))
|
|
486
|
+
.sort((a, b) => b.points - a.points);
|
|
487
|
+
if (response_format === 'json') {
|
|
488
|
+
return {
|
|
489
|
+
content: [{ type: 'text', text: JSON.stringify(data, null, 2) }],
|
|
490
|
+
};
|
|
491
|
+
}
|
|
492
|
+
const lines = [
|
|
493
|
+
`# Leaderboard (${unit.charAt(0).toUpperCase() + unit.slice(1)})`,
|
|
494
|
+
'',
|
|
495
|
+
];
|
|
496
|
+
for (const [rank, entry] of data.entries()) {
|
|
497
|
+
const name = entry.display_name ?? entry.username;
|
|
498
|
+
lines.push(`${rank + 1}. **${name}** (@${entry.username}) — ${entry.points} pts`);
|
|
499
|
+
}
|
|
500
|
+
return { content: [{ type: 'text', text: lines.join('\n') }] };
|
|
501
|
+
}
|
|
502
|
+
catch (err) {
|
|
503
|
+
return { content: [{ type: 'text', text: handleError(err) }] };
|
|
504
|
+
}
|
|
505
|
+
});
|
|
506
|
+
// -------------------------------------------------------------------------
|
|
507
|
+
// Get Courses (all subjects: language, math, chess, music)
|
|
508
|
+
// -------------------------------------------------------------------------
|
|
509
|
+
server.registerTool('duolingo_get_courses', {
|
|
510
|
+
title: 'Get Duolingo Courses',
|
|
511
|
+
description: 'Get all courses a Duolingo user is enrolled in, including non-language subjects ' +
|
|
512
|
+
"like Math, Chess, and Music. Returns each course's subject, title, XP earned, " +
|
|
513
|
+
'and course ID. Language courses also include the language code.',
|
|
514
|
+
inputSchema: {
|
|
515
|
+
username: UsernameFieldSchema,
|
|
516
|
+
response_format: ResponseFormatSchema,
|
|
517
|
+
},
|
|
518
|
+
annotations: {
|
|
519
|
+
readOnlyHint: true,
|
|
520
|
+
destructiveHint: false,
|
|
521
|
+
idempotentHint: true,
|
|
522
|
+
openWorldHint: true,
|
|
523
|
+
},
|
|
524
|
+
}, async ({ username, response_format }) => {
|
|
525
|
+
try {
|
|
526
|
+
const client = getClient();
|
|
527
|
+
// Resolve user ID: use authenticated user's ID if no username given,
|
|
528
|
+
// otherwise look up the target user's ID via the v2 API.
|
|
529
|
+
let userId;
|
|
530
|
+
if (!username) {
|
|
531
|
+
const userData = await client.getUserData();
|
|
532
|
+
userId = userData.id;
|
|
533
|
+
}
|
|
534
|
+
else {
|
|
535
|
+
userId = await client.getUserIdByUsername(username);
|
|
536
|
+
}
|
|
537
|
+
const v2 = await client.getUserDataV2(userId);
|
|
538
|
+
const courses = v2.courses;
|
|
539
|
+
if (courses.length === 0) {
|
|
540
|
+
return {
|
|
541
|
+
content: [{ type: 'text', text: 'No courses found.' }],
|
|
542
|
+
};
|
|
543
|
+
}
|
|
544
|
+
if (response_format === 'json') {
|
|
545
|
+
return {
|
|
546
|
+
content: [{ type: 'text', text: JSON.stringify(courses, null, 2) }],
|
|
547
|
+
};
|
|
548
|
+
}
|
|
549
|
+
const SUBJECT_LABELS = {
|
|
550
|
+
language: '🌐 Language',
|
|
551
|
+
math: '🔢 Math',
|
|
552
|
+
chess: '♟️ Chess',
|
|
553
|
+
music: '🎵 Music',
|
|
554
|
+
};
|
|
555
|
+
const lines = [`# Courses for ${v2.username}`, ''];
|
|
556
|
+
for (const course of courses) {
|
|
557
|
+
const label = SUBJECT_LABELS[course.subject] ?? course.subject;
|
|
558
|
+
const title = course.title ??
|
|
559
|
+
course.subject.charAt(0).toUpperCase() + course.subject.slice(1);
|
|
560
|
+
const lang = course.learningLanguage
|
|
561
|
+
? ` (${course.learningLanguage})`
|
|
562
|
+
: '';
|
|
563
|
+
lines.push(`- **${label}: ${title}${lang}** — ${course.xp.toLocaleString()} XP`);
|
|
564
|
+
}
|
|
565
|
+
return { content: [{ type: 'text', text: lines.join('\n') }] };
|
|
566
|
+
}
|
|
567
|
+
catch (err) {
|
|
568
|
+
return { content: [{ type: 'text', text: handleError(err) }] };
|
|
569
|
+
}
|
|
570
|
+
});
|
|
571
|
+
// -------------------------------------------------------------------------
|
|
572
|
+
// Get Shop Items
|
|
573
|
+
// -------------------------------------------------------------------------
|
|
574
|
+
server.registerTool('duolingo_get_shop_items', {
|
|
575
|
+
title: 'Get Duolingo Shop Items',
|
|
576
|
+
description: 'Get the full Duolingo shop catalogue. Returns all purchasable items with ' +
|
|
577
|
+
'their prices, currency type (gems/lingots), item type, and last-used dates. ' +
|
|
578
|
+
'This is read-only — it does not purchase anything.',
|
|
579
|
+
inputSchema: {
|
|
580
|
+
response_format: ResponseFormatSchema,
|
|
581
|
+
},
|
|
582
|
+
annotations: {
|
|
583
|
+
readOnlyHint: true,
|
|
584
|
+
destructiveHint: false,
|
|
585
|
+
idempotentHint: true,
|
|
586
|
+
openWorldHint: true,
|
|
587
|
+
},
|
|
588
|
+
}, async ({ response_format }) => {
|
|
589
|
+
try {
|
|
590
|
+
const items = await getClient().getShopItems();
|
|
591
|
+
if (items.length === 0) {
|
|
592
|
+
return { content: [{ type: 'text', text: 'No shop items found.' }] };
|
|
593
|
+
}
|
|
594
|
+
if (response_format === 'json') {
|
|
595
|
+
return {
|
|
596
|
+
content: [{ type: 'text', text: JSON.stringify(items, null, 2) }],
|
|
597
|
+
};
|
|
598
|
+
}
|
|
599
|
+
const lines = ['# Duolingo Shop', ''];
|
|
600
|
+
// Group by type
|
|
601
|
+
const byType = new Map();
|
|
602
|
+
for (const item of items) {
|
|
603
|
+
const group = byType.get(item.type) ?? [];
|
|
604
|
+
group.push(item);
|
|
605
|
+
byType.set(item.type, group);
|
|
606
|
+
}
|
|
607
|
+
for (const [type, typeItems] of byType) {
|
|
608
|
+
lines.push(`## ${type.replace(/_/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase())}`);
|
|
609
|
+
for (const item of typeItems) {
|
|
610
|
+
const name = item.name ?? item.id;
|
|
611
|
+
const currency = item.currencyType === 'XGM' ? 'gems' : 'lingots';
|
|
612
|
+
lines.push(`- **${name}** — ${item.price} ${currency}`);
|
|
613
|
+
}
|
|
614
|
+
lines.push('');
|
|
615
|
+
}
|
|
616
|
+
return { content: [{ type: 'text', text: lines.join('\n') }] };
|
|
617
|
+
}
|
|
618
|
+
catch (err) {
|
|
619
|
+
return { content: [{ type: 'text', text: handleError(err) }] };
|
|
620
|
+
}
|
|
621
|
+
});
|
|
622
|
+
// -------------------------------------------------------------------------
|
|
623
|
+
// Get Health (hearts)
|
|
624
|
+
// -------------------------------------------------------------------------
|
|
625
|
+
server.registerTool('duolingo_get_health', {
|
|
626
|
+
title: 'Get Duolingo Health (Hearts)',
|
|
627
|
+
description: "Get the authenticated user's current hearts/health status. " +
|
|
628
|
+
'Returns heart count, max hearts, refill eligibility, and time until next heart refill. ' +
|
|
629
|
+
'Only works for the authenticated user.',
|
|
630
|
+
inputSchema: {
|
|
631
|
+
response_format: ResponseFormatSchema,
|
|
632
|
+
},
|
|
633
|
+
annotations: {
|
|
634
|
+
readOnlyHint: true,
|
|
635
|
+
destructiveHint: false,
|
|
636
|
+
idempotentHint: true,
|
|
637
|
+
openWorldHint: true,
|
|
638
|
+
},
|
|
639
|
+
}, async ({ response_format }) => {
|
|
640
|
+
try {
|
|
641
|
+
const health = await getClient().getHealth();
|
|
642
|
+
if (response_format === 'json') {
|
|
643
|
+
return {
|
|
644
|
+
content: [{ type: 'text', text: JSON.stringify(health, null, 2) }],
|
|
645
|
+
};
|
|
646
|
+
}
|
|
647
|
+
const lines = ['# Hearts / Health', ''];
|
|
648
|
+
lines.push(`- **Hearts**: ${health.hearts} / ${health.maxHearts}`);
|
|
649
|
+
lines.push(`- **Health Enabled**: ${health.healthEnabled ? 'Yes' : 'No'}`);
|
|
650
|
+
lines.push(`- **Unlimited Hearts**: ${health.unlimitedHeartsAvailable ? 'Yes' : 'No'}`);
|
|
651
|
+
lines.push(`- **Eligible for Free Refill**: ${health.eligibleForFreeRefill ? 'Yes' : 'No'}`);
|
|
652
|
+
if (health.secondsUntilNextHeartSegment !== null) {
|
|
653
|
+
const mins = Math.ceil(health.secondsUntilNextHeartSegment / 60);
|
|
654
|
+
lines.push(`- **Next Heart In**: ${mins} min`);
|
|
655
|
+
}
|
|
656
|
+
return { content: [{ type: 'text', text: lines.join('\n') }] };
|
|
657
|
+
}
|
|
658
|
+
catch (err) {
|
|
659
|
+
return { content: [{ type: 'text', text: handleError(err) }] };
|
|
660
|
+
}
|
|
661
|
+
});
|
|
662
|
+
// -------------------------------------------------------------------------
|
|
663
|
+
// Get Currencies (gems + lingots)
|
|
664
|
+
// -------------------------------------------------------------------------
|
|
665
|
+
server.registerTool('duolingo_get_currencies', {
|
|
666
|
+
title: 'Get Duolingo Currency Balances',
|
|
667
|
+
description: "Get the authenticated user's gem and lingot balances. " +
|
|
668
|
+
'Only works for the authenticated user.',
|
|
669
|
+
inputSchema: {
|
|
670
|
+
response_format: ResponseFormatSchema,
|
|
671
|
+
},
|
|
672
|
+
annotations: {
|
|
673
|
+
readOnlyHint: true,
|
|
674
|
+
destructiveHint: false,
|
|
675
|
+
idempotentHint: true,
|
|
676
|
+
openWorldHint: true,
|
|
677
|
+
},
|
|
678
|
+
}, async ({ response_format }) => {
|
|
679
|
+
try {
|
|
680
|
+
const currencies = await getClient().getCurrencies();
|
|
681
|
+
if (response_format === 'json') {
|
|
682
|
+
return {
|
|
683
|
+
content: [
|
|
684
|
+
{ type: 'text', text: JSON.stringify(currencies, null, 2) },
|
|
685
|
+
],
|
|
686
|
+
};
|
|
687
|
+
}
|
|
688
|
+
const lines = ['# Currency Balances', ''];
|
|
689
|
+
lines.push(`- **Gems**: ${currencies.gems.toLocaleString()}`);
|
|
690
|
+
lines.push(`- **Lingots**: ${currencies.lingots.toLocaleString()}`);
|
|
691
|
+
return { content: [{ type: 'text', text: lines.join('\n') }] };
|
|
692
|
+
}
|
|
693
|
+
catch (err) {
|
|
694
|
+
return { content: [{ type: 'text', text: handleError(err) }] };
|
|
695
|
+
}
|
|
696
|
+
});
|
|
697
|
+
// -------------------------------------------------------------------------
|
|
698
|
+
// Get Streak Goal
|
|
699
|
+
// -------------------------------------------------------------------------
|
|
700
|
+
server.registerTool('duolingo_get_streak_goal', {
|
|
701
|
+
title: 'Get Duolingo Streak Goal',
|
|
702
|
+
description: "Get the authenticated user's current streak goal and upcoming checkpoints. " +
|
|
703
|
+
'Shows the last completed goal, upcoming milestones, and the next selected goal. ' +
|
|
704
|
+
'Only works for the authenticated user.',
|
|
705
|
+
inputSchema: {
|
|
706
|
+
response_format: ResponseFormatSchema,
|
|
707
|
+
},
|
|
708
|
+
annotations: {
|
|
709
|
+
readOnlyHint: true,
|
|
710
|
+
destructiveHint: false,
|
|
711
|
+
idempotentHint: true,
|
|
712
|
+
openWorldHint: true,
|
|
713
|
+
},
|
|
714
|
+
}, async ({ response_format }) => {
|
|
715
|
+
try {
|
|
716
|
+
const data = await getClient().getStreakGoalCurrent();
|
|
717
|
+
if (response_format === 'json') {
|
|
718
|
+
return {
|
|
719
|
+
content: [{ type: 'text', text: JSON.stringify(data, null, 2) }],
|
|
720
|
+
};
|
|
721
|
+
}
|
|
722
|
+
if (!data.hasActiveGoal || !data.streakGoal) {
|
|
723
|
+
return {
|
|
724
|
+
content: [{ type: 'text', text: 'No active streak goal.' }],
|
|
725
|
+
};
|
|
726
|
+
}
|
|
727
|
+
const goal = data.streakGoal;
|
|
728
|
+
const lines = ['# Streak Goal', ''];
|
|
729
|
+
lines.push(`- **Last Completed Goal**: ${goal.lastCompleteGoal} days`);
|
|
730
|
+
if (goal.nextSelectedGoal) {
|
|
731
|
+
lines.push(`- **Next Goal**: ${goal.nextSelectedGoal.length} days (every ${goal.nextSelectedGoal.dayInterval} days)`);
|
|
732
|
+
}
|
|
733
|
+
if (goal.checkpoints.length > 0) {
|
|
734
|
+
lines.push('', '## Upcoming Checkpoints');
|
|
735
|
+
for (const cp of goal.checkpoints) {
|
|
736
|
+
lines.push(`- ${cp.length} days`);
|
|
737
|
+
}
|
|
738
|
+
}
|
|
739
|
+
return { content: [{ type: 'text', text: lines.join('\n') }] };
|
|
740
|
+
}
|
|
741
|
+
catch (err) {
|
|
742
|
+
return { content: [{ type: 'text', text: handleError(err) }] };
|
|
743
|
+
}
|
|
744
|
+
});
|
|
745
|
+
}
|
|
746
|
+
//# sourceMappingURL=account.js.map
|