fanfou 0.1.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 +122 -0
- package/dist/args.js +91 -0
- package/dist/client.js +490 -0
- package/dist/commands.js +777 -0
- package/dist/config.js +81 -0
- package/dist/index.js +100 -0
- package/dist/oauth1.js +73 -0
- package/dist/output.js +80 -0
- package/dist/registry.js +122 -0
- package/package.json +45 -0
- package/skills/fanfou/SKILL.md +122 -0
package/dist/commands.js
ADDED
|
@@ -0,0 +1,777 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import { basename, extname } from "node:path";
|
|
3
|
+
import { createInterface } from "node:readline/promises";
|
|
4
|
+
import { FanfouClient } from "./client.js";
|
|
5
|
+
import { flagBool, flagNumber, flagString } from "./args.js";
|
|
6
|
+
import { clearProfile, listProfiles, resolveProfile, saveConfig, loadConfig, saveProfile, configDir, } from "./config.js";
|
|
7
|
+
// ---- shared helpers -----------------------------------------------------
|
|
8
|
+
function timelineParams(ctx, defaults = {}) {
|
|
9
|
+
return {
|
|
10
|
+
id: flagString(ctx.flags, "id") ?? defaults.id,
|
|
11
|
+
sinceId: flagString(ctx.flags, "since-id") ?? defaults.sinceId,
|
|
12
|
+
maxId: flagString(ctx.flags, "max-id") ?? defaults.maxId,
|
|
13
|
+
count: flagNumber(ctx.flags, "count") ?? defaults.count,
|
|
14
|
+
page: flagNumber(ctx.flags, "page") ?? defaults.page,
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
const MIME_BY_EXT = {
|
|
18
|
+
".jpg": "image/jpeg",
|
|
19
|
+
".jpeg": "image/jpeg",
|
|
20
|
+
".png": "image/png",
|
|
21
|
+
".gif": "image/gif",
|
|
22
|
+
".webp": "image/webp",
|
|
23
|
+
};
|
|
24
|
+
function readUpload(path, fieldName) {
|
|
25
|
+
const data = readFileSync(path);
|
|
26
|
+
const ext = extname(path).toLowerCase();
|
|
27
|
+
return {
|
|
28
|
+
fieldName,
|
|
29
|
+
fileName: basename(path),
|
|
30
|
+
mimeType: MIME_BY_EXT[ext] ?? "application/octet-stream",
|
|
31
|
+
data,
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
function parseKv(value) {
|
|
35
|
+
if (!value)
|
|
36
|
+
return [];
|
|
37
|
+
const out = [];
|
|
38
|
+
for (const [k, v] of new URLSearchParams(value).entries())
|
|
39
|
+
out.push([k, v]);
|
|
40
|
+
return out;
|
|
41
|
+
}
|
|
42
|
+
function requireArg(ctx, index, name) {
|
|
43
|
+
const value = ctx.args[index];
|
|
44
|
+
if (value === undefined || value === "") {
|
|
45
|
+
throw new UsageError(`缺少参数 <${name}> (missing argument)`);
|
|
46
|
+
}
|
|
47
|
+
return value;
|
|
48
|
+
}
|
|
49
|
+
export class UsageError extends Error {
|
|
50
|
+
constructor(message) {
|
|
51
|
+
super(message);
|
|
52
|
+
this.name = "UsageError";
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
const TIMELINE_FLAGS = [
|
|
56
|
+
{ name: "count", type: "number", description: "Number of items to fetch" },
|
|
57
|
+
{ name: "since-id", type: "string", description: "Only items newer than this id" },
|
|
58
|
+
{ name: "max-id", type: "string", description: "Only items older than or equal to this id" },
|
|
59
|
+
{ name: "page", type: "number", description: "Page number" },
|
|
60
|
+
{ name: "id", type: "string", description: "Target user id (loginname)" },
|
|
61
|
+
];
|
|
62
|
+
// ---- run implementations (shared by full commands and shortcuts) --------
|
|
63
|
+
const runHome = (ctx) => ctx.client.homeTimeline(timelineParams(ctx));
|
|
64
|
+
const runPublic = (ctx) => ctx.client.publicTimeline(timelineParams(ctx));
|
|
65
|
+
const runUserTimeline = (ctx) => ctx.client.userTimeline(timelineParams(ctx));
|
|
66
|
+
const runMentions = (ctx) => ctx.client.mentions(timelineParams(ctx));
|
|
67
|
+
async function runPost(ctx) {
|
|
68
|
+
const text = requireArg(ctx, 0, "text");
|
|
69
|
+
return ctx.client.updateStatus({
|
|
70
|
+
status: text,
|
|
71
|
+
inReplyToStatusId: flagString(ctx.flags, "reply-to-status"),
|
|
72
|
+
inReplyToUserId: flagString(ctx.flags, "reply-to-user"),
|
|
73
|
+
repostStatusId: flagString(ctx.flags, "repost-status"),
|
|
74
|
+
location: flagString(ctx.flags, "location"),
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
async function runReply(ctx) {
|
|
78
|
+
const id = requireArg(ctx, 0, "status-id");
|
|
79
|
+
const text = requireArg(ctx, 1, "text");
|
|
80
|
+
let replyUserId = flagString(ctx.flags, "reply-to-user");
|
|
81
|
+
let finalText = text;
|
|
82
|
+
if (!ctx.dryRun) {
|
|
83
|
+
const original = (await ctx.client.showStatus(id));
|
|
84
|
+
replyUserId = replyUserId ?? original.user?.id;
|
|
85
|
+
const name = original.user?.name;
|
|
86
|
+
if (name && !text.includes(`@${name}`))
|
|
87
|
+
finalText = `@${name} ${text}`;
|
|
88
|
+
}
|
|
89
|
+
return ctx.client.updateStatus({ status: finalText, inReplyToStatusId: id, inReplyToUserId: replyUserId });
|
|
90
|
+
}
|
|
91
|
+
async function runRepost(ctx) {
|
|
92
|
+
const id = requireArg(ctx, 0, "status-id");
|
|
93
|
+
let text = ctx.args[1];
|
|
94
|
+
if (!text) {
|
|
95
|
+
if (ctx.dryRun) {
|
|
96
|
+
text = "转发";
|
|
97
|
+
}
|
|
98
|
+
else {
|
|
99
|
+
const original = (await ctx.client.showStatus(id));
|
|
100
|
+
text = `转@${original.user?.name ?? ""} ${original.text ?? ""}`.trim();
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
return ctx.client.updateStatus({ status: text, repostStatusId: id });
|
|
104
|
+
}
|
|
105
|
+
async function runMe(ctx) {
|
|
106
|
+
return ctx.client.verifyCredentials();
|
|
107
|
+
}
|
|
108
|
+
const runSearchStatuses = (ctx) => ctx.client.searchPublicTimeline(requireArg(ctx, 0, "query"), timelineParams(ctx));
|
|
109
|
+
const runSearchUsers = (ctx) => ctx.client.searchUsers(requireArg(ctx, 0, "query"), {
|
|
110
|
+
count: flagNumber(ctx.flags, "count"),
|
|
111
|
+
page: flagNumber(ctx.flags, "page"),
|
|
112
|
+
});
|
|
113
|
+
const runFavAdd = (ctx) => ctx.client.createFavorite(requireArg(ctx, 0, "status-id"));
|
|
114
|
+
const runDmSend = (ctx) => ctx.client.sendDirectMessage({
|
|
115
|
+
user: requireArg(ctx, 0, "user-id"),
|
|
116
|
+
text: requireArg(ctx, 1, "text"),
|
|
117
|
+
inReplyToId: flagString(ctx.flags, "reply-to"),
|
|
118
|
+
});
|
|
119
|
+
// ---- auth commands ------------------------------------------------------
|
|
120
|
+
async function runLogin(ctx) {
|
|
121
|
+
if (flagBool(ctx.flags, "web"))
|
|
122
|
+
return runWebLoginInteractive(ctx);
|
|
123
|
+
let username = flagString(ctx.flags, "username") ?? process.env.FANFOU_USERNAME;
|
|
124
|
+
let password = flagString(ctx.flags, "password") ?? process.env.FANFOU_PASSWORD;
|
|
125
|
+
if (flagBool(ctx.flags, "password-stdin")) {
|
|
126
|
+
password = (await readAllStdin()).trim();
|
|
127
|
+
}
|
|
128
|
+
if (!username || !password) {
|
|
129
|
+
throw new UsageError("请提供 --username 和 --password(或设置 FANFOU_USERNAME / FANFOU_PASSWORD,或用 --web 走网页授权)");
|
|
130
|
+
}
|
|
131
|
+
const { token, secret } = await ctx.client.xauthLogin(username, password);
|
|
132
|
+
return finalizeLogin(ctx, token, secret);
|
|
133
|
+
}
|
|
134
|
+
async function finalizeLogin(ctx, token, secret) {
|
|
135
|
+
const { profile } = resolveProfile(ctx.profileName);
|
|
136
|
+
const verifyClient = new FanfouClient({
|
|
137
|
+
consumerKey: profile.consumerKey,
|
|
138
|
+
consumerSecret: profile.consumerSecret,
|
|
139
|
+
token,
|
|
140
|
+
tokenSecret: secret,
|
|
141
|
+
});
|
|
142
|
+
const user = (await verifyClient.verifyCredentials());
|
|
143
|
+
saveProfile(ctx.profileName, {
|
|
144
|
+
consumerKey: profile.consumerKey,
|
|
145
|
+
consumerSecret: profile.consumerSecret,
|
|
146
|
+
token,
|
|
147
|
+
tokenSecret: secret,
|
|
148
|
+
user: { id: user.id, name: user.name, screenName: user.screen_name },
|
|
149
|
+
});
|
|
150
|
+
return { ok: true, profile: ctx.profileName, user: { id: user.id, name: user.name } };
|
|
151
|
+
}
|
|
152
|
+
async function runOAuthUrl(ctx) {
|
|
153
|
+
const callback = flagString(ctx.flags, "callback") ?? "oob";
|
|
154
|
+
const { token, secret } = await ctx.client.requestToken(callback);
|
|
155
|
+
return {
|
|
156
|
+
authorize_url: ctx.client.authorizeURL(token, callback === "oob" ? undefined : callback),
|
|
157
|
+
request_token: token,
|
|
158
|
+
request_token_secret: secret,
|
|
159
|
+
next: "在浏览器中打开 authorize_url 完成授权,然后运行:fanfou auth oauth-exchange --token <request_token> --secret <request_token_secret> [--verifier <code>]",
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
async function runOAuthExchange(ctx) {
|
|
163
|
+
const token = flagString(ctx.flags, "token");
|
|
164
|
+
const secret = flagString(ctx.flags, "secret");
|
|
165
|
+
if (!token || !secret)
|
|
166
|
+
throw new UsageError("请提供 --token 和 --secret(来自 oauth-url 的输出)");
|
|
167
|
+
const verifier = flagString(ctx.flags, "verifier");
|
|
168
|
+
const access = await ctx.client.accessToken(token, secret, verifier);
|
|
169
|
+
return finalizeLogin(ctx, access.token, access.secret);
|
|
170
|
+
}
|
|
171
|
+
async function runWebLoginInteractive(ctx) {
|
|
172
|
+
const { token, secret } = await ctx.client.requestToken("oob");
|
|
173
|
+
const url = ctx.client.authorizeURL(token);
|
|
174
|
+
process.stderr.write(`请在浏览器打开以下链接完成授权:\n${url}\n`);
|
|
175
|
+
if (!process.stdin.isTTY) {
|
|
176
|
+
return {
|
|
177
|
+
authorize_url: url,
|
|
178
|
+
request_token: token,
|
|
179
|
+
request_token_secret: secret,
|
|
180
|
+
next: "非交互环境:授权后运行 fanfou auth oauth-exchange --token ... --secret ... [--verifier ...]",
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
const rl = createInterface({ input: process.stdin, output: process.stderr });
|
|
184
|
+
const verifier = (await rl.question("授权完成后粘贴 verifier(没有就直接回车): ")).trim();
|
|
185
|
+
rl.close();
|
|
186
|
+
const access = await ctx.client.accessToken(token, secret, verifier || undefined);
|
|
187
|
+
return finalizeLogin(ctx, access.token, access.secret);
|
|
188
|
+
}
|
|
189
|
+
function runLogout(ctx) {
|
|
190
|
+
clearProfile(ctx.profileName);
|
|
191
|
+
return { ok: true, profile: ctx.profileName, message: "已退出登录" };
|
|
192
|
+
}
|
|
193
|
+
function runAuthStatus(ctx) {
|
|
194
|
+
const { name, profile } = resolveProfile(ctx.profileName);
|
|
195
|
+
return {
|
|
196
|
+
profile: name,
|
|
197
|
+
authenticated: Boolean(profile.token && profile.tokenSecret),
|
|
198
|
+
user: profile.user,
|
|
199
|
+
consumerKey: profile.consumerKey,
|
|
200
|
+
configDir: configDir(),
|
|
201
|
+
};
|
|
202
|
+
}
|
|
203
|
+
function runProfilesList() {
|
|
204
|
+
return listProfiles();
|
|
205
|
+
}
|
|
206
|
+
function runUseProfile(ctx) {
|
|
207
|
+
const name = requireArg(ctx, 0, "profile");
|
|
208
|
+
const config = loadConfig();
|
|
209
|
+
config.currentProfile = name;
|
|
210
|
+
saveConfig(config);
|
|
211
|
+
return { ok: true, currentProfile: name };
|
|
212
|
+
}
|
|
213
|
+
async function readAllStdin() {
|
|
214
|
+
const chunks = [];
|
|
215
|
+
for await (const chunk of process.stdin)
|
|
216
|
+
chunks.push(chunk);
|
|
217
|
+
return Buffer.concat(chunks).toString("utf8");
|
|
218
|
+
}
|
|
219
|
+
// ---- raw api command ----------------------------------------------------
|
|
220
|
+
async function runApi(ctx) {
|
|
221
|
+
const method = requireArg(ctx, 0, "method").toUpperCase();
|
|
222
|
+
if (method !== "GET" && method !== "POST")
|
|
223
|
+
throw new UsageError("method 必须是 GET 或 POST");
|
|
224
|
+
const path = requireArg(ctx, 1, "path");
|
|
225
|
+
const base = flagString(ctx.flags, "base") ?? "api";
|
|
226
|
+
const query = parseKv(flagString(ctx.flags, "query"));
|
|
227
|
+
const form = parseKv(flagString(ctx.flags, "form"));
|
|
228
|
+
if (flagBool(ctx.flags, "raw")) {
|
|
229
|
+
const { text, plan } = await ctx.client.request({ base, path, method, query, form });
|
|
230
|
+
return ctx.dryRun ? { dryRun: true, request: plan } : text;
|
|
231
|
+
}
|
|
232
|
+
return ctx.client.requestJSON({ base, path, method, query, form });
|
|
233
|
+
}
|
|
234
|
+
// ---- command tree -------------------------------------------------------
|
|
235
|
+
export function buildRootCommand() {
|
|
236
|
+
const timeline = {
|
|
237
|
+
name: "timeline",
|
|
238
|
+
summary: "时间线:关注/公开/某人/提到我",
|
|
239
|
+
subcommands: [
|
|
240
|
+
{
|
|
241
|
+
name: "home",
|
|
242
|
+
summary: "关注时间线(首页刷饭)",
|
|
243
|
+
flags: TIMELINE_FLAGS,
|
|
244
|
+
requiresAuth: true,
|
|
245
|
+
run: runHome,
|
|
246
|
+
examples: ["fanfou timeline home --count 10", "fanfou +timeline"],
|
|
247
|
+
},
|
|
248
|
+
{ name: "public", summary: "公开时间线", flags: TIMELINE_FLAGS, run: runPublic },
|
|
249
|
+
{
|
|
250
|
+
name: "user",
|
|
251
|
+
summary: "某个用户的消息时间线",
|
|
252
|
+
flags: TIMELINE_FLAGS,
|
|
253
|
+
requiresAuth: true,
|
|
254
|
+
run: runUserTimeline,
|
|
255
|
+
examples: ["fanfou timeline user --id someone --count 20"],
|
|
256
|
+
},
|
|
257
|
+
{ name: "mentions", summary: "提到我的消息", flags: TIMELINE_FLAGS, requiresAuth: true, run: runMentions },
|
|
258
|
+
{
|
|
259
|
+
name: "context",
|
|
260
|
+
summary: "一条消息的上下文时间线",
|
|
261
|
+
args: [{ name: "status-id", description: "消息 id", required: true }],
|
|
262
|
+
requiresAuth: true,
|
|
263
|
+
run: (ctx) => ctx.client.contextTimeline(requireArg(ctx, 0, "status-id")),
|
|
264
|
+
},
|
|
265
|
+
{ name: "photos", summary: "用户的照片时间线", flags: TIMELINE_FLAGS, requiresAuth: true, run: (ctx) => ctx.client.photoUserTimeline(timelineParams(ctx)) },
|
|
266
|
+
],
|
|
267
|
+
};
|
|
268
|
+
const status = {
|
|
269
|
+
name: "status",
|
|
270
|
+
summary: "消息:查看/发布/回复/转发/删除",
|
|
271
|
+
subcommands: [
|
|
272
|
+
{
|
|
273
|
+
name: "show",
|
|
274
|
+
summary: "查看一条消息",
|
|
275
|
+
args: [{ name: "status-id", description: "消息 id", required: true }],
|
|
276
|
+
requiresAuth: true,
|
|
277
|
+
run: (ctx) => ctx.client.showStatus(requireArg(ctx, 0, "status-id")),
|
|
278
|
+
},
|
|
279
|
+
{
|
|
280
|
+
name: "post",
|
|
281
|
+
summary: "发布一条消息(发饭)",
|
|
282
|
+
args: [{ name: "text", description: "消息正文(≤140字)", required: true }],
|
|
283
|
+
flags: [
|
|
284
|
+
{ name: "reply-to-status", type: "string", description: "回复的消息 id" },
|
|
285
|
+
{ name: "reply-to-user", type: "string", description: "回复的用户 id" },
|
|
286
|
+
{ name: "repost-status", type: "string", description: "转发的消息 id" },
|
|
287
|
+
{ name: "location", type: "string", description: "位置" },
|
|
288
|
+
],
|
|
289
|
+
requiresAuth: true,
|
|
290
|
+
mutates: true,
|
|
291
|
+
run: runPost,
|
|
292
|
+
examples: ['fanfou status post "今天天气不错"', 'fanfou +post "Hello 饭否"'],
|
|
293
|
+
},
|
|
294
|
+
{
|
|
295
|
+
name: "reply",
|
|
296
|
+
summary: "回复一条消息(自动补全 @用户 与 in_reply_to)",
|
|
297
|
+
args: [
|
|
298
|
+
{ name: "status-id", description: "被回复的消息 id", required: true },
|
|
299
|
+
{ name: "text", description: "回复正文", required: true },
|
|
300
|
+
],
|
|
301
|
+
flags: [{ name: "reply-to-user", type: "string", description: "覆盖回复的用户 id" }],
|
|
302
|
+
requiresAuth: true,
|
|
303
|
+
mutates: true,
|
|
304
|
+
run: runReply,
|
|
305
|
+
examples: ['fanfou status reply <id> "说得对"'],
|
|
306
|
+
},
|
|
307
|
+
{
|
|
308
|
+
name: "repost",
|
|
309
|
+
summary: "转发一条消息(不带正文则生成「转@用户 原文」)",
|
|
310
|
+
args: [
|
|
311
|
+
{ name: "status-id", description: "被转发的消息 id", required: true },
|
|
312
|
+
{ name: "text", description: "转发附言(可选)" },
|
|
313
|
+
],
|
|
314
|
+
requiresAuth: true,
|
|
315
|
+
mutates: true,
|
|
316
|
+
run: runRepost,
|
|
317
|
+
},
|
|
318
|
+
{
|
|
319
|
+
name: "delete",
|
|
320
|
+
summary: "删除自己的一条消息",
|
|
321
|
+
args: [{ name: "status-id", description: "消息 id", required: true }],
|
|
322
|
+
requiresAuth: true,
|
|
323
|
+
mutates: true,
|
|
324
|
+
run: (ctx) => ctx.client.destroyStatus(requireArg(ctx, 0, "status-id")),
|
|
325
|
+
},
|
|
326
|
+
{
|
|
327
|
+
name: "photo",
|
|
328
|
+
summary: "上传图片并发布消息",
|
|
329
|
+
args: [
|
|
330
|
+
{ name: "image-path", description: "本地图片路径", required: true },
|
|
331
|
+
{ name: "text", description: "消息正文", required: true },
|
|
332
|
+
],
|
|
333
|
+
flags: [{ name: "location", type: "string", description: "位置" }],
|
|
334
|
+
requiresAuth: true,
|
|
335
|
+
mutates: true,
|
|
336
|
+
run: (ctx) => ctx.client.uploadPhoto(readUpload(requireArg(ctx, 0, "image-path"), "photo"), requireArg(ctx, 1, "text"), flagString(ctx.flags, "location")),
|
|
337
|
+
},
|
|
338
|
+
],
|
|
339
|
+
};
|
|
340
|
+
const favorite = {
|
|
341
|
+
name: "favorite",
|
|
342
|
+
summary: "收藏:列表/添加/移除",
|
|
343
|
+
subcommands: [
|
|
344
|
+
{
|
|
345
|
+
name: "list",
|
|
346
|
+
summary: "收藏列表",
|
|
347
|
+
flags: [
|
|
348
|
+
{ name: "id", type: "string", description: "目标用户 id(默认自己)" },
|
|
349
|
+
{ name: "count", type: "number", description: "数量" },
|
|
350
|
+
{ name: "page", type: "number", description: "页码" },
|
|
351
|
+
],
|
|
352
|
+
requiresAuth: true,
|
|
353
|
+
run: (ctx) => ctx.client.favorites({
|
|
354
|
+
id: flagString(ctx.flags, "id"),
|
|
355
|
+
count: flagNumber(ctx.flags, "count"),
|
|
356
|
+
page: flagNumber(ctx.flags, "page"),
|
|
357
|
+
}),
|
|
358
|
+
},
|
|
359
|
+
{
|
|
360
|
+
name: "add",
|
|
361
|
+
summary: "收藏一条消息",
|
|
362
|
+
args: [{ name: "status-id", description: "消息 id", required: true }],
|
|
363
|
+
requiresAuth: true,
|
|
364
|
+
mutates: true,
|
|
365
|
+
run: runFavAdd,
|
|
366
|
+
},
|
|
367
|
+
{
|
|
368
|
+
name: "remove",
|
|
369
|
+
summary: "取消收藏",
|
|
370
|
+
args: [{ name: "status-id", description: "消息 id", required: true }],
|
|
371
|
+
requiresAuth: true,
|
|
372
|
+
mutates: true,
|
|
373
|
+
run: (ctx) => ctx.client.destroyFavorite(requireArg(ctx, 0, "status-id")),
|
|
374
|
+
},
|
|
375
|
+
],
|
|
376
|
+
};
|
|
377
|
+
const user = {
|
|
378
|
+
name: "user",
|
|
379
|
+
summary: "用户:资料/关注/粉丝/关注关系/拉黑",
|
|
380
|
+
subcommands: [
|
|
381
|
+
{
|
|
382
|
+
name: "show",
|
|
383
|
+
summary: "查看用户资料(缺省查看自己)",
|
|
384
|
+
args: [{ name: "id", description: "用户 id(loginname),缺省为自己" }],
|
|
385
|
+
requiresAuth: true,
|
|
386
|
+
run: (ctx) => ctx.client.showUser(ctx.args[0] ?? flagString(ctx.flags, "id")),
|
|
387
|
+
examples: ["fanfou user show", "fanfou user show someone"],
|
|
388
|
+
},
|
|
389
|
+
{
|
|
390
|
+
name: "friends",
|
|
391
|
+
summary: "某人的关注列表",
|
|
392
|
+
flags: [
|
|
393
|
+
{ name: "id", type: "string", description: "用户 id(默认自己)" },
|
|
394
|
+
{ name: "count", type: "number", description: "数量" },
|
|
395
|
+
{ name: "page", type: "number", description: "页码" },
|
|
396
|
+
],
|
|
397
|
+
requiresAuth: true,
|
|
398
|
+
run: (ctx) => ctx.client.friends({ id: flagString(ctx.flags, "id"), count: flagNumber(ctx.flags, "count"), page: flagNumber(ctx.flags, "page") }),
|
|
399
|
+
},
|
|
400
|
+
{
|
|
401
|
+
name: "followers",
|
|
402
|
+
summary: "某人的粉丝列表",
|
|
403
|
+
flags: [
|
|
404
|
+
{ name: "id", type: "string", description: "用户 id(默认自己)" },
|
|
405
|
+
{ name: "count", type: "number", description: "数量" },
|
|
406
|
+
{ name: "page", type: "number", description: "页码" },
|
|
407
|
+
],
|
|
408
|
+
requiresAuth: true,
|
|
409
|
+
run: (ctx) => ctx.client.followers({ id: flagString(ctx.flags, "id"), count: flagNumber(ctx.flags, "count"), page: flagNumber(ctx.flags, "page") }),
|
|
410
|
+
},
|
|
411
|
+
{
|
|
412
|
+
name: "follow",
|
|
413
|
+
summary: "关注用户",
|
|
414
|
+
args: [{ name: "id", description: "用户 id", required: true }],
|
|
415
|
+
requiresAuth: true,
|
|
416
|
+
mutates: true,
|
|
417
|
+
run: (ctx) => ctx.client.followUser(requireArg(ctx, 0, "id")),
|
|
418
|
+
},
|
|
419
|
+
{
|
|
420
|
+
name: "unfollow",
|
|
421
|
+
summary: "取消关注",
|
|
422
|
+
args: [{ name: "id", description: "用户 id", required: true }],
|
|
423
|
+
requiresAuth: true,
|
|
424
|
+
mutates: true,
|
|
425
|
+
run: (ctx) => ctx.client.unfollowUser(requireArg(ctx, 0, "id")),
|
|
426
|
+
},
|
|
427
|
+
{
|
|
428
|
+
name: "search",
|
|
429
|
+
summary: "搜索饭友",
|
|
430
|
+
args: [{ name: "query", description: "关键词", required: true }],
|
|
431
|
+
flags: [
|
|
432
|
+
{ name: "count", type: "number", description: "数量" },
|
|
433
|
+
{ name: "page", type: "number", description: "页码" },
|
|
434
|
+
],
|
|
435
|
+
requiresAuth: true,
|
|
436
|
+
run: runSearchUsers,
|
|
437
|
+
},
|
|
438
|
+
{
|
|
439
|
+
name: "block",
|
|
440
|
+
summary: "拉黑用户",
|
|
441
|
+
args: [{ name: "id", description: "用户 id", required: true }],
|
|
442
|
+
requiresAuth: true,
|
|
443
|
+
mutates: true,
|
|
444
|
+
run: (ctx) => ctx.client.blockUser(requireArg(ctx, 0, "id")),
|
|
445
|
+
},
|
|
446
|
+
{
|
|
447
|
+
name: "unblock",
|
|
448
|
+
summary: "取消拉黑",
|
|
449
|
+
args: [{ name: "id", description: "用户 id", required: true }],
|
|
450
|
+
requiresAuth: true,
|
|
451
|
+
mutates: true,
|
|
452
|
+
run: (ctx) => ctx.client.unblockUser(requireArg(ctx, 0, "id")),
|
|
453
|
+
},
|
|
454
|
+
{
|
|
455
|
+
name: "blocks",
|
|
456
|
+
summary: "黑名单列表",
|
|
457
|
+
flags: [
|
|
458
|
+
{ name: "count", type: "number", description: "数量" },
|
|
459
|
+
{ name: "page", type: "number", description: "页码" },
|
|
460
|
+
],
|
|
461
|
+
requiresAuth: true,
|
|
462
|
+
run: (ctx) => ctx.client.blockedUsers({ count: flagNumber(ctx.flags, "count"), page: flagNumber(ctx.flags, "page") }),
|
|
463
|
+
},
|
|
464
|
+
{
|
|
465
|
+
name: "blocked",
|
|
466
|
+
summary: "检查是否已拉黑某用户",
|
|
467
|
+
args: [{ name: "id", description: "用户 id", required: true }],
|
|
468
|
+
requiresAuth: true,
|
|
469
|
+
run: async (ctx) => ({ id: ctx.args[0], blocked: await ctx.client.isBlocked(requireArg(ctx, 0, "id")) }),
|
|
470
|
+
},
|
|
471
|
+
],
|
|
472
|
+
};
|
|
473
|
+
const friendship = {
|
|
474
|
+
name: "friendship",
|
|
475
|
+
summary: "关注关系:请求/接受/拒绝/校验",
|
|
476
|
+
subcommands: [
|
|
477
|
+
{
|
|
478
|
+
name: "requests",
|
|
479
|
+
summary: "待处理的关注请求",
|
|
480
|
+
flags: [
|
|
481
|
+
{ name: "count", type: "number", description: "数量" },
|
|
482
|
+
{ name: "page", type: "number", description: "页码" },
|
|
483
|
+
],
|
|
484
|
+
requiresAuth: true,
|
|
485
|
+
run: (ctx) => ctx.client.friendshipRequests({ count: flagNumber(ctx.flags, "count"), page: flagNumber(ctx.flags, "page") }),
|
|
486
|
+
},
|
|
487
|
+
{
|
|
488
|
+
name: "accept",
|
|
489
|
+
summary: "接受关注请求",
|
|
490
|
+
args: [{ name: "id", description: "用户 id", required: true }],
|
|
491
|
+
requiresAuth: true,
|
|
492
|
+
mutates: true,
|
|
493
|
+
run: (ctx) => ctx.client.acceptFriendship(requireArg(ctx, 0, "id")),
|
|
494
|
+
},
|
|
495
|
+
{
|
|
496
|
+
name: "deny",
|
|
497
|
+
summary: "拒绝关注请求",
|
|
498
|
+
args: [{ name: "id", description: "用户 id", required: true }],
|
|
499
|
+
requiresAuth: true,
|
|
500
|
+
mutates: true,
|
|
501
|
+
run: (ctx) => ctx.client.denyFriendship(requireArg(ctx, 0, "id")),
|
|
502
|
+
},
|
|
503
|
+
{
|
|
504
|
+
name: "exists",
|
|
505
|
+
summary: "判断 A 是否关注了 B",
|
|
506
|
+
args: [
|
|
507
|
+
{ name: "user-a", description: "用户 A", required: true },
|
|
508
|
+
{ name: "user-b", description: "用户 B", required: true },
|
|
509
|
+
],
|
|
510
|
+
requiresAuth: true,
|
|
511
|
+
run: async (ctx) => ({
|
|
512
|
+
user_a: ctx.args[0],
|
|
513
|
+
user_b: ctx.args[1],
|
|
514
|
+
follows: await ctx.client.friendshipExists(requireArg(ctx, 0, "user-a"), requireArg(ctx, 1, "user-b")),
|
|
515
|
+
}),
|
|
516
|
+
},
|
|
517
|
+
],
|
|
518
|
+
};
|
|
519
|
+
const dm = {
|
|
520
|
+
name: "dm",
|
|
521
|
+
summary: "私信:会话/收发/删除",
|
|
522
|
+
subcommands: [
|
|
523
|
+
{
|
|
524
|
+
name: "list",
|
|
525
|
+
summary: "会话列表",
|
|
526
|
+
flags: [
|
|
527
|
+
{ name: "count", type: "number", description: "数量" },
|
|
528
|
+
{ name: "page", type: "number", description: "页码" },
|
|
529
|
+
],
|
|
530
|
+
requiresAuth: true,
|
|
531
|
+
run: (ctx) => ctx.client.conversationList({ count: flagNumber(ctx.flags, "count"), page: flagNumber(ctx.flags, "page") }),
|
|
532
|
+
},
|
|
533
|
+
{
|
|
534
|
+
name: "thread",
|
|
535
|
+
summary: "与某人的私信会话",
|
|
536
|
+
args: [{ name: "user-id", description: "对方用户 id", required: true }],
|
|
537
|
+
flags: TIMELINE_FLAGS,
|
|
538
|
+
requiresAuth: true,
|
|
539
|
+
run: (ctx) => ctx.client.conversation(requireArg(ctx, 0, "user-id"), timelineParams(ctx)),
|
|
540
|
+
},
|
|
541
|
+
{ name: "inbox", summary: "收件箱", flags: TIMELINE_FLAGS, requiresAuth: true, run: (ctx) => ctx.client.inbox(timelineParams(ctx)) },
|
|
542
|
+
{ name: "sent", summary: "发件箱", flags: TIMELINE_FLAGS, requiresAuth: true, run: (ctx) => ctx.client.sent(timelineParams(ctx)) },
|
|
543
|
+
{
|
|
544
|
+
name: "send",
|
|
545
|
+
summary: "发送私信",
|
|
546
|
+
args: [
|
|
547
|
+
{ name: "user-id", description: "对方用户 id", required: true },
|
|
548
|
+
{ name: "text", description: "私信内容", required: true },
|
|
549
|
+
],
|
|
550
|
+
flags: [{ name: "reply-to", type: "string", description: "回复的私信 id" }],
|
|
551
|
+
requiresAuth: true,
|
|
552
|
+
mutates: true,
|
|
553
|
+
run: runDmSend,
|
|
554
|
+
},
|
|
555
|
+
{
|
|
556
|
+
name: "delete",
|
|
557
|
+
summary: "删除一条私信",
|
|
558
|
+
args: [{ name: "id", description: "私信 id", required: true }],
|
|
559
|
+
requiresAuth: true,
|
|
560
|
+
mutates: true,
|
|
561
|
+
run: (ctx) => ctx.client.deleteDirectMessage(requireArg(ctx, 0, "id")),
|
|
562
|
+
},
|
|
563
|
+
],
|
|
564
|
+
};
|
|
565
|
+
const account = {
|
|
566
|
+
name: "account",
|
|
567
|
+
summary: "账号:校验/通知数/资料/头像",
|
|
568
|
+
subcommands: [
|
|
569
|
+
{ name: "verify", summary: "校验当前登录用户", requiresAuth: true, run: runMe },
|
|
570
|
+
{ name: "notification", summary: "未读通知计数", requiresAuth: true, run: (ctx) => ctx.client.notification() },
|
|
571
|
+
{
|
|
572
|
+
name: "update-profile",
|
|
573
|
+
summary: "更新个人资料",
|
|
574
|
+
flags: [
|
|
575
|
+
{ name: "name", type: "string", description: "昵称" },
|
|
576
|
+
{ name: "location", type: "string", description: "位置" },
|
|
577
|
+
{ name: "url", type: "string", description: "主页" },
|
|
578
|
+
{ name: "description", type: "string", description: "简介" },
|
|
579
|
+
],
|
|
580
|
+
requiresAuth: true,
|
|
581
|
+
mutates: true,
|
|
582
|
+
run: (ctx) => ctx.client.updateProfile({
|
|
583
|
+
name: flagString(ctx.flags, "name"),
|
|
584
|
+
location: flagString(ctx.flags, "location"),
|
|
585
|
+
url: flagString(ctx.flags, "url"),
|
|
586
|
+
description: flagString(ctx.flags, "description"),
|
|
587
|
+
}),
|
|
588
|
+
},
|
|
589
|
+
{
|
|
590
|
+
name: "update-avatar",
|
|
591
|
+
summary: "更换头像",
|
|
592
|
+
args: [{ name: "image-path", description: "本地图片路径", required: true }],
|
|
593
|
+
requiresAuth: true,
|
|
594
|
+
mutates: true,
|
|
595
|
+
run: (ctx) => ctx.client.updateProfileImage(readUpload(requireArg(ctx, 0, "image-path"), "image")),
|
|
596
|
+
},
|
|
597
|
+
],
|
|
598
|
+
};
|
|
599
|
+
const search = {
|
|
600
|
+
name: "search",
|
|
601
|
+
summary: "搜索:公开消息/饭友",
|
|
602
|
+
subcommands: [
|
|
603
|
+
{
|
|
604
|
+
name: "statuses",
|
|
605
|
+
summary: "搜索公开消息",
|
|
606
|
+
args: [{ name: "query", description: "关键词", required: true }],
|
|
607
|
+
flags: [
|
|
608
|
+
{ name: "count", type: "number", description: "数量" },
|
|
609
|
+
{ name: "since-id", type: "string", description: "起始 id" },
|
|
610
|
+
{ name: "max-id", type: "string", description: "结束 id" },
|
|
611
|
+
],
|
|
612
|
+
run: runSearchStatuses,
|
|
613
|
+
},
|
|
614
|
+
{
|
|
615
|
+
name: "users",
|
|
616
|
+
summary: "搜索饭友",
|
|
617
|
+
args: [{ name: "query", description: "关键词", required: true }],
|
|
618
|
+
flags: [
|
|
619
|
+
{ name: "count", type: "number", description: "数量" },
|
|
620
|
+
{ name: "page", type: "number", description: "页码" },
|
|
621
|
+
],
|
|
622
|
+
requiresAuth: true,
|
|
623
|
+
run: runSearchUsers,
|
|
624
|
+
},
|
|
625
|
+
],
|
|
626
|
+
};
|
|
627
|
+
const auth = {
|
|
628
|
+
name: "auth",
|
|
629
|
+
summary: "登录鉴权:XAuth / OAuth 网页授权 / 多账号",
|
|
630
|
+
subcommands: [
|
|
631
|
+
{
|
|
632
|
+
name: "login",
|
|
633
|
+
summary: "用账号密码登录(XAuth),或加 --web 走网页授权",
|
|
634
|
+
flags: [
|
|
635
|
+
{ name: "username", alias: "u", type: "string", description: "饭否用户名/邮箱" },
|
|
636
|
+
{ name: "password", alias: "p", type: "string", description: "密码" },
|
|
637
|
+
{ name: "password-stdin", type: "boolean", description: "从标准输入读取密码" },
|
|
638
|
+
{ name: "web", type: "boolean", description: "改用 OAuth 网页授权流程" },
|
|
639
|
+
],
|
|
640
|
+
run: runLogin,
|
|
641
|
+
examples: [
|
|
642
|
+
'fanfou auth login -u myname -p "secret"',
|
|
643
|
+
"FANFOU_USERNAME=... FANFOU_PASSWORD=... fanfou auth login",
|
|
644
|
+
"fanfou auth login --web",
|
|
645
|
+
],
|
|
646
|
+
},
|
|
647
|
+
{
|
|
648
|
+
name: "oauth-url",
|
|
649
|
+
summary: "OAuth 第一步:获取授权链接与 request token",
|
|
650
|
+
flags: [{ name: "callback", type: "string", description: "回调地址(默认 oob)" }],
|
|
651
|
+
run: runOAuthUrl,
|
|
652
|
+
},
|
|
653
|
+
{
|
|
654
|
+
name: "oauth-exchange",
|
|
655
|
+
summary: "OAuth 第二步:用授权后的 request token 换取 access token",
|
|
656
|
+
flags: [
|
|
657
|
+
{ name: "token", type: "string", description: "request token", required: true },
|
|
658
|
+
{ name: "secret", type: "string", description: "request token secret", required: true },
|
|
659
|
+
{ name: "verifier", type: "string", description: "授权后拿到的 verifier(如有)" },
|
|
660
|
+
],
|
|
661
|
+
run: runOAuthExchange,
|
|
662
|
+
},
|
|
663
|
+
{ name: "logout", summary: "退出当前 profile 的登录", run: runLogout },
|
|
664
|
+
{ name: "status", summary: "查看当前登录状态(不联网)", run: runAuthStatus },
|
|
665
|
+
{ name: "whoami", summary: "校验并返回当前登录用户", requiresAuth: true, run: runMe },
|
|
666
|
+
{ name: "profiles", summary: "列出所有账号 profile", run: runProfilesList },
|
|
667
|
+
{
|
|
668
|
+
name: "use",
|
|
669
|
+
summary: "切换当前默认 profile",
|
|
670
|
+
args: [{ name: "profile", description: "profile 名称", required: true }],
|
|
671
|
+
run: runUseProfile,
|
|
672
|
+
},
|
|
673
|
+
],
|
|
674
|
+
};
|
|
675
|
+
const api = {
|
|
676
|
+
name: "api",
|
|
677
|
+
summary: "直连任意饭否 API 端点(底层逃生通道)",
|
|
678
|
+
description: "对任意 Fanfou REST 端点发起已签名的请求。method 为 GET/POST,path 形如 statuses/home_timeline.json。",
|
|
679
|
+
args: [
|
|
680
|
+
{ name: "method", description: "GET 或 POST", required: true },
|
|
681
|
+
{ name: "path", description: "API 路径,如 statuses/home_timeline.json", required: true },
|
|
682
|
+
],
|
|
683
|
+
flags: [
|
|
684
|
+
{ name: "query", type: "string", description: "查询参数,k=v&k2=v2 形式" },
|
|
685
|
+
{ name: "form", type: "string", description: "POST 表单参数,k=v&k2=v2 形式" },
|
|
686
|
+
{ name: "base", type: "string", description: "api(默认)或 oauth" },
|
|
687
|
+
{ name: "raw", type: "boolean", description: "原样输出响应文本,不解析 JSON" },
|
|
688
|
+
],
|
|
689
|
+
requiresAuth: true,
|
|
690
|
+
mutates: true,
|
|
691
|
+
run: runApi,
|
|
692
|
+
examples: [
|
|
693
|
+
"fanfou api GET statuses/home_timeline.json --query count=5",
|
|
694
|
+
'fanfou api POST statuses/update.json --form "status=hi&source=fanfou-cli"',
|
|
695
|
+
],
|
|
696
|
+
};
|
|
697
|
+
// Shortcuts (layer 1): + prefixed convenience commands with smart defaults.
|
|
698
|
+
const shortcuts = [
|
|
699
|
+
{
|
|
700
|
+
name: "+timeline",
|
|
701
|
+
summary: "快捷:关注时间线(默认 20 条)",
|
|
702
|
+
flags: TIMELINE_FLAGS,
|
|
703
|
+
requiresAuth: true,
|
|
704
|
+
run: (ctx) => ctx.client.homeTimeline(timelineParams(ctx, { count: 20 })),
|
|
705
|
+
},
|
|
706
|
+
{
|
|
707
|
+
name: "+post",
|
|
708
|
+
summary: "快捷:发布一条消息",
|
|
709
|
+
args: [{ name: "text", description: "消息正文", required: true }],
|
|
710
|
+
requiresAuth: true,
|
|
711
|
+
mutates: true,
|
|
712
|
+
run: runPost,
|
|
713
|
+
},
|
|
714
|
+
{
|
|
715
|
+
name: "+reply",
|
|
716
|
+
summary: "快捷:回复一条消息",
|
|
717
|
+
args: [
|
|
718
|
+
{ name: "status-id", description: "被回复的消息 id", required: true },
|
|
719
|
+
{ name: "text", description: "回复正文", required: true },
|
|
720
|
+
],
|
|
721
|
+
requiresAuth: true,
|
|
722
|
+
mutates: true,
|
|
723
|
+
run: runReply,
|
|
724
|
+
},
|
|
725
|
+
{
|
|
726
|
+
name: "+repost",
|
|
727
|
+
summary: "快捷:转发一条消息",
|
|
728
|
+
args: [
|
|
729
|
+
{ name: "status-id", description: "被转发的消息 id", required: true },
|
|
730
|
+
{ name: "text", description: "附言(可选)" },
|
|
731
|
+
],
|
|
732
|
+
requiresAuth: true,
|
|
733
|
+
mutates: true,
|
|
734
|
+
run: runRepost,
|
|
735
|
+
},
|
|
736
|
+
{ name: "+mentions", summary: "快捷:提到我的消息", flags: TIMELINE_FLAGS, requiresAuth: true, run: (ctx) => ctx.client.mentions(timelineParams(ctx, { count: 20 })) },
|
|
737
|
+
{ name: "+me", summary: "快捷:我的资料", requiresAuth: true, run: runMe },
|
|
738
|
+
{
|
|
739
|
+
name: "+search",
|
|
740
|
+
summary: "快捷:搜索公开消息",
|
|
741
|
+
args: [{ name: "query", description: "关键词", required: true }],
|
|
742
|
+
run: runSearchStatuses,
|
|
743
|
+
},
|
|
744
|
+
{
|
|
745
|
+
name: "+fav",
|
|
746
|
+
summary: "快捷:收藏一条消息",
|
|
747
|
+
args: [{ name: "status-id", description: "消息 id", required: true }],
|
|
748
|
+
requiresAuth: true,
|
|
749
|
+
mutates: true,
|
|
750
|
+
run: runFavAdd,
|
|
751
|
+
},
|
|
752
|
+
{
|
|
753
|
+
name: "+dm",
|
|
754
|
+
summary: "快捷:发送私信",
|
|
755
|
+
args: [
|
|
756
|
+
{ name: "user-id", description: "对方用户 id", required: true },
|
|
757
|
+
{ name: "text", description: "私信内容", required: true },
|
|
758
|
+
],
|
|
759
|
+
requiresAuth: true,
|
|
760
|
+
mutates: true,
|
|
761
|
+
run: runDmSend,
|
|
762
|
+
},
|
|
763
|
+
];
|
|
764
|
+
return {
|
|
765
|
+
name: "fanfou",
|
|
766
|
+
summary: "面向大模型友好的饭否(Fanfou)命令行",
|
|
767
|
+
description: "三层结构:① + 开头的快捷命令(高频,自带默认值);② 资源子命令(timeline/status/user/dm/...);③ api 直连任意端点。默认输出 JSON,便于程序与 Agent 解析。",
|
|
768
|
+
subcommands: [auth, timeline, status, favorite, user, friendship, dm, account, search, api, ...shortcuts],
|
|
769
|
+
examples: [
|
|
770
|
+
"fanfou auth login -u <name> -p <pass>",
|
|
771
|
+
"fanfou +timeline",
|
|
772
|
+
'fanfou +post "Hello 饭否"',
|
|
773
|
+
"fanfou timeline mentions --count 5 --format table",
|
|
774
|
+
"fanfou api GET users/show.json --query id=someone",
|
|
775
|
+
],
|
|
776
|
+
};
|
|
777
|
+
}
|