feishu-user-plugin 1.3.5 → 1.3.7

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.
Files changed (56) hide show
  1. package/.claude-plugin/plugin.json +2 -2
  2. package/CHANGELOG.md +22 -0
  3. package/README.md +66 -40
  4. package/package.json +10 -3
  5. package/scripts/check-tool-count.js +15 -0
  6. package/scripts/check-version.js +40 -0
  7. package/scripts/smoke.js +224 -0
  8. package/scripts/sync-claude-md.sh +12 -0
  9. package/scripts/sync-team-skills.sh +22 -0
  10. package/scripts/test-all-tools.js +158 -0
  11. package/skills/feishu-user-plugin/SKILL.md +5 -5
  12. package/skills/feishu-user-plugin/references/CLAUDE.md +152 -96
  13. package/skills/feishu-user-plugin/references/table.md +18 -9
  14. package/src/auth/credentials.js +350 -0
  15. package/src/cli.js +42 -13
  16. package/src/clients/official/base.js +424 -0
  17. package/src/clients/official/bitable.js +269 -0
  18. package/src/clients/official/calendar.js +176 -0
  19. package/src/clients/official/contacts.js +54 -0
  20. package/src/clients/official/docs.js +301 -0
  21. package/src/clients/official/drive.js +77 -0
  22. package/src/clients/official/groups.js +68 -0
  23. package/src/clients/official/im.js +414 -0
  24. package/src/clients/official/index.js +30 -0
  25. package/src/clients/official/okr.js +127 -0
  26. package/src/clients/official/tasks.js +142 -0
  27. package/src/clients/official/uploads.js +260 -0
  28. package/src/clients/official/wiki.js +207 -0
  29. package/src/{client.js → clients/user.js} +23 -17
  30. package/src/doc-blocks.js +20 -5
  31. package/src/index.js +4 -1744
  32. package/src/logger.js +20 -0
  33. package/src/oauth.js +8 -1
  34. package/src/official.js +5 -1734
  35. package/src/prompts/_registry.js +69 -0
  36. package/src/prompts/index.js +54 -0
  37. package/src/server.js +242 -0
  38. package/src/test-all.js +2 -2
  39. package/src/test-comprehensive.js +3 -3
  40. package/src/test-send.js +1 -1
  41. package/src/tools/_registry.js +30 -0
  42. package/src/tools/bitable.js +246 -0
  43. package/src/tools/calendar.js +207 -0
  44. package/src/tools/contacts.js +66 -0
  45. package/src/tools/diagnostics.js +172 -0
  46. package/src/tools/docs.js +158 -0
  47. package/src/tools/drive.js +111 -0
  48. package/src/tools/groups.js +81 -0
  49. package/src/tools/im-read.js +259 -0
  50. package/src/tools/messaging-bot.js +151 -0
  51. package/src/tools/messaging-user.js +292 -0
  52. package/src/tools/okr.js +159 -0
  53. package/src/tools/profile.js +43 -0
  54. package/src/tools/tasks.js +168 -0
  55. package/src/tools/uploads.js +63 -0
  56. package/src/tools/wiki.js +191 -0
@@ -0,0 +1,69 @@
1
+ // src/prompts/_registry.js — Load the 9 sub-skill markdown files as MCP prompt definitions.
2
+ //
3
+ // Reads from skills/feishu-user-plugin/references/ (whitelisted set).
4
+ // For each file:
5
+ // - name: filename without .md
6
+ // - description: first non-empty line
7
+ // - arguments: [{name:'arguments', description, required:false}] if body contains $ARGUMENTS
8
+ // - body: full file content (used for substitution in getPrompt)
9
+
10
+ 'use strict';
11
+
12
+ const fs = require('fs');
13
+ const path = require('path');
14
+
15
+ const WHITELIST = new Set(['send', 'reply', 'digest', 'search', 'doc', 'table', 'wiki', 'drive', 'status']);
16
+ const REFS_DIR = path.join(__dirname, '..', '..', 'skills', 'feishu-user-plugin', 'references');
17
+
18
+ /**
19
+ * Parse $ARGUMENTS description from the ## 参数 section.
20
+ * Looks for a line matching "- $ARGUMENTS:<desc>" or "- $ARGUMENTS: <desc>".
21
+ * Returns the description text, or a generic fallback.
22
+ */
23
+ function parseArgumentsDescription(body) {
24
+ const match = body.match(/\$ARGUMENTS[::]\s*(.+)/);
25
+ if (match) {
26
+ return match[1].trim();
27
+ }
28
+ return 'Skill arguments';
29
+ }
30
+
31
+ /**
32
+ * Load all whitelisted sub-skill files and return prompt definitions.
33
+ * @returns {Array<{name: string, description: string, arguments?: Array, body: string}>}
34
+ */
35
+ function loadAllSkills() {
36
+ const prompts = [];
37
+ for (const name of WHITELIST) {
38
+ const filePath = path.join(REFS_DIR, `${name}.md`);
39
+ let content;
40
+ try {
41
+ content = fs.readFileSync(filePath, 'utf8');
42
+ } catch (e) {
43
+ console.error(`[feishu-user-plugin] prompts: could not read ${filePath}: ${e.message}`);
44
+ continue;
45
+ }
46
+
47
+ // description = first non-empty line
48
+ const lines = content.split('\n');
49
+ const firstNonEmpty = lines.find((l) => l.trim() !== '');
50
+ const description = firstNonEmpty ? firstNonEmpty.trim() : name;
51
+
52
+ const hasArguments = content.includes('$ARGUMENTS');
53
+ const prompt = {
54
+ name,
55
+ description,
56
+ body: content,
57
+ };
58
+ if (hasArguments) {
59
+ const argDesc = parseArgumentsDescription(content);
60
+ prompt.arguments = [{ name: 'arguments', description: argDesc, required: false }];
61
+ }
62
+ prompts.push(prompt);
63
+ }
64
+ // Sort deterministically by name
65
+ prompts.sort((a, b) => a.name.localeCompare(b.name));
66
+ return prompts;
67
+ }
68
+
69
+ module.exports = { loadAllSkills };
@@ -0,0 +1,54 @@
1
+ // src/prompts/index.js — MCP prompt registry: listPrompts + getPrompt.
2
+ //
3
+ // Loaded once at module load; cached for the lifetime of the server process.
4
+ // listPrompts() → MCP prompts/list shaped array (no body exposed in listing).
5
+ // getPrompt(name, args) → MCP prompts/get shaped result with $ARGUMENTS substituted.
6
+
7
+ 'use strict';
8
+
9
+ const { loadAllSkills } = require('./_registry');
10
+
11
+ // Cache all skills once at module load.
12
+ const _skills = loadAllSkills();
13
+ const _byName = new Map(_skills.map((s) => [s.name, s]));
14
+
15
+ /**
16
+ * Returns the spec-shaped prompts array for prompts/list.
17
+ * Omits the body field; omits arguments if empty.
18
+ */
19
+ function listPrompts() {
20
+ return _skills.map((s) => {
21
+ const p = { name: s.name, description: s.description };
22
+ if (s.arguments && s.arguments.length > 0) {
23
+ p.arguments = s.arguments;
24
+ }
25
+ return p;
26
+ });
27
+ }
28
+
29
+ /**
30
+ * Returns the prompts/get result for a given prompt name.
31
+ * Substitutes $ARGUMENTS in the body with args.arguments (or empty string).
32
+ * @param {string} name
33
+ * @param {object} args - e.g. { arguments: "Alice: hi" }
34
+ * @returns {{ description: string, messages: [{role: 'user', content: {type: 'text', text: string}}] }}
35
+ */
36
+ function getPrompt(name, args = {}) {
37
+ const skill = _byName.get(name);
38
+ if (!skill) {
39
+ throw new Error(`Unknown prompt: ${name}`);
40
+ }
41
+ const argValue = args.arguments != null ? String(args.arguments) : '';
42
+ const text = skill.body.replace(/\$ARGUMENTS/g, argValue);
43
+ return {
44
+ description: skill.description,
45
+ messages: [
46
+ {
47
+ role: 'user',
48
+ content: { type: 'text', text },
49
+ },
50
+ ],
51
+ };
52
+ }
53
+
54
+ module.exports = { listPrompts, getPrompt };
package/src/server.js ADDED
@@ -0,0 +1,242 @@
1
+ // src/server.js — MCP bootstrap, tool registration, request dispatch.
2
+ //
3
+ // What this owns:
4
+ // - Loading every src/tools/<domain>.js module and flattening its schemas.
5
+ // - Building the ctx object that handlers receive (factory closures + profile state).
6
+ // - The MCP Server instance and its ListTools / CallTool request handlers.
7
+ // - Startup diagnostics (auth status, APP_ID validation).
8
+ //
9
+ // What it does NOT own:
10
+ // - Tool definitions: those live in src/tools/<domain>.js.
11
+ // - Feishu API calls: those live in src/clients/{user,official}.
12
+ // - Auth lifecycle (cookie heartbeat, UAT refresh, file lock): src/auth/*.
13
+ // - Config discovery / persistence: src/config/*.
14
+
15
+ const path = require('path');
16
+ const { Server } = require('@modelcontextprotocol/sdk/server/index.js');
17
+ const { StdioServerTransport } = require('@modelcontextprotocol/sdk/server/stdio.js');
18
+ const { CallToolRequestSchema, ListToolsRequestSchema, ListPromptsRequestSchema, GetPromptRequestSchema } = require('@modelcontextprotocol/sdk/types.js');
19
+
20
+ // Local dev fallback: MCP clients inject env vars from config's env block at
21
+ // spawn time. This dotenv line only matters when running locally with a .env.
22
+ require('dotenv').config({ path: path.join(__dirname, '..', '.env') });
23
+
24
+ const { LarkUserClient } = require('./clients/user');
25
+ const { LarkOfficialClient } = require('./clients/official');
26
+ const { resolveToken } = require('./resolver');
27
+ const { listPrompts, getPrompt } = require('./prompts');
28
+ const credentials = require('./auth/credentials');
29
+
30
+ // --- Tool modules ---
31
+ // Adding a new domain: create src/tools/<x>.js exporting { schemas, handlers }
32
+ // and append it here. The schemas are concatenated into the MCP tools/list
33
+ // response; the handlers are looked up by name when tools/call comes in.
34
+ const TOOL_MODULES = [
35
+ require('./tools/bitable'),
36
+ require('./tools/calendar'),
37
+ require('./tools/contacts'),
38
+ require('./tools/diagnostics'),
39
+ require('./tools/docs'),
40
+ require('./tools/drive'),
41
+ require('./tools/groups'),
42
+ require('./tools/im-read'),
43
+ require('./tools/messaging-bot'),
44
+ require('./tools/messaging-user'),
45
+ require('./tools/okr'),
46
+ require('./tools/profile'),
47
+ require('./tools/tasks'),
48
+ require('./tools/uploads'),
49
+ require('./tools/wiki'),
50
+ ];
51
+
52
+ const TOOLS = TOOL_MODULES.flatMap((m) => m.schemas);
53
+ const HANDLERS = Object.fromEntries(TOOL_MODULES.flatMap((m) => Object.entries(m.handlers)));
54
+
55
+ // --- Profile system + client singletons ---
56
+ // Profile resolution order (see src/auth/credentials.js):
57
+ // 1. ~/.feishu-user-plugin/credentials.json — single source of truth (v1.3.7+)
58
+ // 2. process.env.LARK_* — legacy default profile (v1.3.6 behaviour)
59
+ // 3. process.env.LARK_PROFILES_JSON — legacy named profiles
60
+ //
61
+ // switch_profile (handler in tools/profile.js) calls ctx.setActiveProfile(n)
62
+ // which resets the cached client singletons; the next tool call rebuilds them.
63
+ // When credentials.json exists, switching also persists the active field so
64
+ // cross-process MCP servers see the same active profile after restart.
65
+
66
+ let userClient = null;
67
+ let officialClient = null;
68
+ // The "current" profile this in-memory MCP server is pinned to. Initialised
69
+ // from the persisted active profile (credentials.json) at boot, but in-process
70
+ // switches may diverge from the persisted active until the next server restart.
71
+ let currentProfile = credentials.getActiveProfileName();
72
+
73
+ function profileEnv(name) {
74
+ return credentials.getActiveProfileEnv(name);
75
+ }
76
+
77
+ async function getUserClient() {
78
+ if (userClient) return userClient;
79
+ const env = profileEnv(currentProfile);
80
+ const cookie = env.LARK_COOKIE;
81
+ if (!cookie) throw new Error(
82
+ `LARK_COOKIE not set for profile "${currentProfile}". To fix:\n` +
83
+ '1. Open https://www.feishu.cn/messenger/ and log in\n' +
84
+ '2. DevTools → Network tab → Disable cache → Reload → Click first request → Request Headers → Cookie → Copy value\n' +
85
+ ' (Do NOT use document.cookie or Application→Cookies — they miss HttpOnly cookies like session/sl_session)\n' +
86
+ '3. Paste the cookie string into your .mcp.json env LARK_COOKIE field, then restart Claude Code\n' +
87
+ 'If Playwright MCP is available: navigate to feishu.cn/messenger/, let user log in, then use context.cookies() to get the full cookie string including HttpOnly cookies.'
88
+ );
89
+ userClient = new LarkUserClient(cookie);
90
+ await userClient.init();
91
+ return userClient;
92
+ }
93
+
94
+ function getOfficialClient() {
95
+ if (officialClient) return officialClient;
96
+ const env = profileEnv(currentProfile);
97
+ const appId = env.LARK_APP_ID;
98
+ const appSecret = env.LARK_APP_SECRET;
99
+ if (!appId || !appSecret) throw new Error(
100
+ `LARK_APP_ID and LARK_APP_SECRET not set for profile "${currentProfile}".\n` +
101
+ 'For team members: these should be pre-filled in your .mcp.json. Check that the config was copied correctly from the team-skills README.\n' +
102
+ 'For external users: create a Custom App at https://open.feishu.cn/app, get the App ID and App Secret, add them to your .mcp.json env.'
103
+ );
104
+ officialClient = new LarkOfficialClient(appId, appSecret);
105
+ // Load UAT directly from the active profile env. With credentials.json the
106
+ // env may differ from process.env (whose LARK_USER_* may be missing if the
107
+ // user moved creds out of harness configs); using profileEnv() here keeps
108
+ // the source of truth consistent with what get*Client() reads above.
109
+ loadUATFromEnv(officialClient, env);
110
+ return officialClient;
111
+ }
112
+
113
+ // Mirror of LarkOfficialClient.loadUAT() but sourced from a specific env block
114
+ // instead of process.env, so credentials.json profiles work uniformly.
115
+ function loadUATFromEnv(client, env) {
116
+ const token = env.LARK_USER_ACCESS_TOKEN;
117
+ const refresh = env.LARK_USER_REFRESH_TOKEN;
118
+ const expires = parseInt(env.LARK_UAT_EXPIRES || '0');
119
+ if (!token) return;
120
+ client._uat = token;
121
+ client._uatRefresh = refresh || null;
122
+ client._uatExpires = expires || client._decodeTokenExpiry(token);
123
+ }
124
+
125
+ // Resolver helper: turn document_id / app_token / wiki node / Feishu URL into
126
+ // a native token. No-op for already-native inputs. See src/resolver.js.
127
+ async function resolveDocId(input) {
128
+ if (!input) return input;
129
+ return resolveToken(input, getOfficialClient());
130
+ }
131
+
132
+ // --- ctx ---
133
+ // What handlers receive in their second argument. Kept stable so tools/* don't
134
+ // reach back into server.js for state. Adding a new ctx field: also document
135
+ // it in src/tools/_registry.js docstring.
136
+ function buildCtx() {
137
+ return {
138
+ getUserClient,
139
+ getOfficialClient,
140
+ listProfiles: () => credentials.listProfileNames(),
141
+ getActiveProfile: () => currentProfile,
142
+ setActiveProfile: (n) => {
143
+ // Validate the profile exists (throws if unknown) before nuking client cache.
144
+ credentials.getActiveProfileEnv(n);
145
+ currentProfile = n;
146
+ userClient = null;
147
+ officialClient = null;
148
+ // Persist the active-field flip when credentials.json exists so peer MCP
149
+ // servers see the new active profile on next read. Tolerated when no file.
150
+ try { credentials.setActiveProfile(n); } catch (_) {}
151
+ },
152
+ resolveDocId,
153
+ };
154
+ }
155
+
156
+ // --- MCP server ---
157
+
158
+ const server = new Server(
159
+ { name: 'feishu-user-plugin', version: require('../package.json').version },
160
+ { capabilities: { tools: {}, prompts: {} } }
161
+ );
162
+
163
+ server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: TOOLS }));
164
+
165
+ server.setRequestHandler(CallToolRequestSchema, async (request) => {
166
+ const { name, arguments: args } = request.params;
167
+ const handler = HANDLERS[name];
168
+ if (!handler) {
169
+ return { content: [{ type: 'text', text: `Unknown tool: ${name}` }], isError: true };
170
+ }
171
+ try {
172
+ return await handler(args || {}, buildCtx());
173
+ } catch (err) {
174
+ return { content: [{ type: 'text', text: `Error: ${err.message}` }], isError: true };
175
+ }
176
+ });
177
+
178
+ server.setRequestHandler(ListPromptsRequestSchema, async () => ({ prompts: listPrompts() }));
179
+
180
+ server.setRequestHandler(GetPromptRequestSchema, async (req) => {
181
+ const { name, arguments: args } = req.params;
182
+ return getPrompt(name, args || {});
183
+ });
184
+
185
+ // --- Process-level error handlers ---
186
+ // Prevent stray promise rejections or uncaught exceptions from killing the MCP server.
187
+ process.on('uncaughtException', (err) => {
188
+ console.error('[feishu-user-plugin] Uncaught exception:', err.message);
189
+ console.error(err.stack);
190
+ });
191
+ process.on('unhandledRejection', (reason) => {
192
+ console.error('[feishu-user-plugin] Unhandled rejection:', reason);
193
+ });
194
+
195
+ // --- main ---
196
+
197
+ async function main() {
198
+ const transport = new StdioServerTransport();
199
+ await server.connect(transport);
200
+
201
+ // Startup diagnostics — use the resolved active-profile env so users on
202
+ // credentials.json (where process.env may not have LARK_*) get accurate flags.
203
+ let activeEnv = {};
204
+ try { activeEnv = profileEnv(currentProfile); } catch (_) { /* unknown profile is reported below */ }
205
+ const hasCanonical = !!credentials.readCanonical();
206
+ const hasCookie = !!activeEnv.LARK_COOKIE;
207
+ const hasApp = !!(activeEnv.LARK_APP_ID && activeEnv.LARK_APP_SECRET);
208
+ const hasUAT = !!activeEnv.LARK_USER_ACCESS_TOKEN;
209
+ const source = hasCanonical ? `credentials.json profile=${currentProfile}` : 'env vars (legacy)';
210
+ console.error(`[feishu-user-plugin] MCP Server v${require('../package.json').version} — ${TOOLS.length} tools, ${listPrompts().length} prompts`);
211
+ console.error(`[feishu-user-plugin] Auth: Cookie=${hasCookie ? 'YES' : 'NO'} App=${hasApp ? 'YES' : 'NO'} UAT=${hasUAT ? 'YES' : 'NO'} (source: ${source})`);
212
+ if (!hasCookie) console.error('[feishu-user-plugin] WARNING: LARK_COOKIE not set — user identity tools (send_to_user, etc.) will fail');
213
+ if (!hasApp) console.error('[feishu-user-plugin] WARNING: LARK_APP_ID/SECRET not set — official API tools (read_messages, docs, etc.) will fail');
214
+ if (!hasUAT) console.error('[feishu-user-plugin] WARNING: LARK_USER_ACCESS_TOKEN not set — P2P chat reading (read_p2p_messages) will fail');
215
+
216
+ // Validate APP_ID/SECRET against Feishu before serving any tool calls.
217
+ // Catches the "Claude filled in a wrong/stale APP_ID during install" failure
218
+ // mode that otherwise surfaces as cryptic 401s on every Official API call
219
+ // (looks like "MCP 掉线" to the user). Non-blocking — we warn but still serve,
220
+ // because the user may only need user-identity (cookie) tools.
221
+ if (hasApp) {
222
+ try {
223
+ const probe = await getOfficialClient().verifyApp();
224
+ if (probe.valid) {
225
+ const nameBit = probe.appName ? ` "${probe.appName}"` : '';
226
+ console.error(`[feishu-user-plugin] App verified: ${probe.appId}${nameBit}`);
227
+ } else {
228
+ console.error(`[feishu-user-plugin] ERROR: LARK_APP_ID=${probe.appId} was REJECTED by Feishu (${probe.error}).`);
229
+ console.error('[feishu-user-plugin] → Every Official API tool call will fail. Likely wrong/stale APP_ID.');
230
+ console.error('[feishu-user-plugin] → Re-run the install prompt from team-skills/plugins/feishu-user-plugin/README.md to get the correct credentials.');
231
+ }
232
+ } catch (e) {
233
+ console.error(`[feishu-user-plugin] WARNING: Could not verify APP_ID (${e.message}); network issue or cold start. Proceeding anyway.`);
234
+ }
235
+ }
236
+ }
237
+
238
+ module.exports = { main, TOOLS, HANDLERS };
239
+
240
+ if (require.main === module) {
241
+ main().catch((err) => { console.error('Fatal:', err); process.exit(1); });
242
+ }
package/src/test-all.js CHANGED
@@ -4,8 +4,8 @@
4
4
  * Sends test messages to "飞书plugin测试群".
5
5
  */
6
6
  require('dotenv').config({ path: require('path').join(__dirname, '..', '.env') });
7
- const { LarkUserClient } = require('./client');
8
- const { LarkOfficialClient } = require('./official');
7
+ const { LarkUserClient } = require('./clients/user');
8
+ const { LarkOfficialClient } = require('./clients/official');
9
9
 
10
10
  const TEST_GROUP = '飞书plugin测试群';
11
11
  const results = [];
@@ -6,8 +6,8 @@
6
6
  const path = require('path');
7
7
  require('dotenv').config({ path: path.join(__dirname, '..', '.env') });
8
8
 
9
- const { LarkUserClient } = require('./client');
10
- const { LarkOfficialClient } = require('./official');
9
+ const { LarkUserClient } = require('./clients/user');
10
+ const { LarkOfficialClient } = require('./clients/official');
11
11
 
12
12
  const results = [];
13
13
 
@@ -247,7 +247,7 @@ async function testUAT() {
247
247
 
248
248
  // 3. End-to-end P2P flow: search → create_p2p → read_p2p_messages
249
249
  try {
250
- const { LarkUserClient } = require('./client');
250
+ const { LarkUserClient } = require('./clients/user');
251
251
  const userClient = new LarkUserClient(process.env.LARK_COOKIE);
252
252
  await userClient.init();
253
253
  const results = await userClient.search('杨一可');
package/src/test-send.js CHANGED
@@ -9,7 +9,7 @@
9
9
  * node src/test-send.js info <chatId> # Get chat info
10
10
  */
11
11
  require('dotenv').config({ path: require('path').join(__dirname, '..', '.env') });
12
- const { LarkUserClient } = require('./client');
12
+ const { LarkUserClient } = require('./clients/user');
13
13
 
14
14
  async function main() {
15
15
  const cookie = process.env.LARK_COOKIE;
@@ -0,0 +1,30 @@
1
+ // src/tools/_registry.js — shared infrastructure for tool modules.
2
+ //
3
+ // Every src/tools/<domain>.js exports:
4
+ // { schemas: [<MCP tool schema objects>], handlers: { [name]: async (args, ctx) => MCPResponse } }
5
+ //
6
+ // The ctx object that handlers receive is built in src/server.js (or, during
7
+ // the v1.3.7 phase A migration, temporarily in src/index.js) and provides:
8
+ // - getUserClient(): Promise<LarkUserClient>
9
+ // - getOfficialClient(): LarkOfficialClient
10
+ // - resolveDocId(x): Promise<string> — wiki-node / URL → native token
11
+ // - listProfiles(): string[] — names from LARK_PROFILES_JSON + 'default'
12
+ // - getActiveProfile():string
13
+ // - setActiveProfile(name): void — invalidates cached clients
14
+ //
15
+ // Response builders below are imported directly by each tool module — they're
16
+ // not on ctx because they're pure functions with no state.
17
+
18
+ const text = (s) => ({ content: [{ type: 'text', text: s }] });
19
+
20
+ // `json` will lift any `fallbackWarning` field to the top of the rendered
21
+ // response so users see the warning before the structured payload. Preserved
22
+ // from index.js v1.3.5 behaviour.
23
+ const json = (o) => {
24
+ const warn = o && typeof o === 'object' && o.fallbackWarning ? `${o.fallbackWarning}\n\n` : '';
25
+ return text(warn + JSON.stringify(o, null, 2));
26
+ };
27
+
28
+ const sendResult = (r, desc) => text(r.success ? desc : `Send failed (status: ${r.status})`);
29
+
30
+ module.exports = { text, json, sendResult };