neoagent 3.1.1-beta.2 → 3.1.1-beta.4

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 (53) hide show
  1. package/extensions/chrome-browser/manifest.json +1 -1
  2. package/extensions/chrome-browser/protocol.mjs +36 -0
  3. package/flutter_app/lib/main_app_shell.dart +9 -3
  4. package/flutter_app/lib/main_controller.dart +49 -0
  5. package/flutter_app/lib/main_models.dart +55 -0
  6. package/flutter_app/lib/main_operations.dart +530 -2
  7. package/flutter_app/lib/main_settings.dart +301 -1
  8. package/flutter_app/lib/main_shared.dart +7 -2
  9. package/flutter_app/lib/main_timeline.dart +82 -11
  10. package/flutter_app/lib/src/backend_client.dart +50 -0
  11. package/landing/index.html +3 -0
  12. package/landing/status.html +178 -0
  13. package/lib/schema_migrations.js +23 -0
  14. package/package.json +1 -1
  15. package/server/db/database.js +6 -0
  16. package/server/http/routes.js +2 -0
  17. package/server/http/static.js +1 -0
  18. package/server/public/.last_build_id +1 -1
  19. package/server/public/assets/fonts/MaterialIcons-Regular.otf +0 -0
  20. package/server/public/flutter_bootstrap.js +1 -1
  21. package/server/public/main.dart.js +76806 -75964
  22. package/server/routes/public_status.js +12 -0
  23. package/server/routes/settings.js +14 -0
  24. package/server/routes/social_reach.js +88 -0
  25. package/server/routes/tasks.js +17 -0
  26. package/server/services/ai/tools.js +60 -1
  27. package/server/services/browser/extension/protocol.js +1 -0
  28. package/server/services/manager.js +14 -0
  29. package/server/services/memory/consolidation.js +2 -0
  30. package/server/services/memory/manager.js +70 -8
  31. package/server/services/memory/retention.js +66 -0
  32. package/server/services/memory/routing.js +68 -0
  33. package/server/services/messaging/access_policy.js +39 -2
  34. package/server/services/messaging/manager.js +9 -4
  35. package/server/services/public_status.js +103 -0
  36. package/server/services/runtime/settings.js +6 -0
  37. package/server/services/social_reach/channels/base.js +56 -0
  38. package/server/services/social_reach/channels/github.js +75 -0
  39. package/server/services/social_reach/channels/index.js +34 -0
  40. package/server/services/social_reach/channels/linkedin.js +34 -0
  41. package/server/services/social_reach/channels/rss.js +58 -0
  42. package/server/services/social_reach/channels/unsupported.js +49 -0
  43. package/server/services/social_reach/channels/v2ex.js +85 -0
  44. package/server/services/social_reach/channels/web.js +40 -0
  45. package/server/services/social_reach/channels/xueqiu.js +101 -0
  46. package/server/services/social_reach/channels/youtube.js +42 -0
  47. package/server/services/social_reach/index.js +7 -0
  48. package/server/services/social_reach/platforms.js +162 -0
  49. package/server/services/social_reach/service.js +163 -0
  50. package/server/services/social_reach/store.js +89 -0
  51. package/server/services/social_reach/utils.js +95 -0
  52. package/server/services/tasks/delivery_targets.js +260 -0
  53. package/server/utils/whatsapp.js +33 -1
@@ -37,7 +37,7 @@ const SHARED_RAW_ID_PLATFORMS = new Set([
37
37
  'webchat',
38
38
  ]);
39
39
 
40
- const DIRECT_ONLY_PHONE_PLATFORMS = new Set(['telnyx', 'whatsapp']);
40
+ const DIRECT_ONLY_PHONE_PLATFORMS = new Set(['telnyx']);
41
41
 
42
42
  function capabilityTemplate(overrides = {}) {
43
43
  return Object.freeze({
@@ -300,6 +300,17 @@ function addRule(bucket, rule, state) {
300
300
  state[bucket].push(rule);
301
301
  }
302
302
 
303
+ function isWhatsAppGroupId(value) {
304
+ return String(value || '').trim().toLowerCase().split(':')[0].endsWith('@g.us');
305
+ }
306
+
307
+ function normalizeWhatsAppDirectId(value) {
308
+ const raw = String(value || '').trim().toLowerCase();
309
+ const base = raw.includes('@') ? raw.split('@')[0] : raw;
310
+ const primary = base.includes(':') ? base.split(':')[0] : base;
311
+ return primary.replace(/\D/g, '') || primary;
312
+ }
313
+
303
314
  function migrateLegacyWhitelist(platform, entries) {
304
315
  const capabilities = getPlatformAccessCapabilities(platform);
305
316
  const policy = createDefaultAccessPolicy(platform);
@@ -328,6 +339,22 @@ function migrateLegacyWhitelist(platform, entries) {
328
339
  const value = sanitizeValue(scope || 'chat', rawValue);
329
340
  if (!value) continue;
330
341
 
342
+ if (platform === 'whatsapp') {
343
+ if (scope === 'group' || isWhatsAppGroupId(value)) {
344
+ addRule('sharedSpaceRules', normalizeRule({ scope: 'group', value }, new Set(capabilities.sharedSpaceRuleScopes)), state);
345
+ continue;
346
+ }
347
+ const directValue = scope === 'chat' ? value : normalizeWhatsAppDirectId(value);
348
+ if (!directValue) continue;
349
+ if (scope === 'chat') {
350
+ addRule('directRules', normalizeRule({ scope: 'chat', value: directValue }, new Set(capabilities.directRuleScopes)), state);
351
+ } else {
352
+ addRule('directRules', normalizeRule({ scope: 'phone_number', value: directValue }, new Set(capabilities.directRuleScopes)), state);
353
+ addRule('sharedActorRules', normalizeRule({ scope: 'phone_number', value: directValue }, new Set(capabilities.sharedActorRuleScopes)), state);
354
+ }
355
+ continue;
356
+ }
357
+
331
358
  if (DIRECT_ONLY_PHONE_PLATFORMS.has(platform)) {
332
359
  addRule('directRules', normalizeRule({ scope: 'phone_number', value }, new Set(capabilities.directRuleScopes)), state);
333
360
  continue;
@@ -641,7 +668,17 @@ function classifyRecentTarget(platform, row) {
641
668
  if (!chatId && !sender) return null;
642
669
 
643
670
  const isDirect = !String(metadata.isGroup || '').match(/^(true|1)$/i) && (!chatId || chatId === sender || chatId === `dm_${sender}`);
644
- if (DIRECT_ONLY_PHONE_PLATFORMS.has(platform)) {
671
+ if (platform === 'whatsapp' && !isDirect && chatId) {
672
+ return {
673
+ source: 'recent',
674
+ bucket: 'sharedSpaceRules',
675
+ scope: 'group',
676
+ value: chatId,
677
+ label: groupName || chatId,
678
+ subtitle: 'Recent WhatsApp group',
679
+ };
680
+ }
681
+ if (DIRECT_ONLY_PHONE_PLATFORMS.has(platform) || platform === 'whatsapp') {
645
682
  const value = normalizePhone(sender || chatId);
646
683
  if (!value) return null;
647
684
  return {
@@ -818,14 +818,19 @@ class MessagingManager extends EventEmitter {
818
818
  );
819
819
  }
820
820
 
821
- async getAccessCatalog(userId, platformName, options = {}) {
821
+ async listAccessTargets(userId, platformName, options = {}) {
822
822
  const agentId = this._agentId(userId, options);
823
823
  const key = this._key(userId, agentId, platformName);
824
824
  const platform = this.platforms.get(key);
825
- let discoveredTargets = [];
826
- if (platform?.listAccessTargets) {
827
- discoveredTargets = await Promise.resolve(platform.listAccessTargets()).catch(() => []);
825
+ if (!platform || typeof platform.listAccessTargets !== 'function') {
826
+ return [];
828
827
  }
828
+ return Promise.resolve(platform.listAccessTargets()).catch(() => []);
829
+ }
830
+
831
+ async getAccessCatalog(userId, platformName, options = {}) {
832
+ const agentId = this._agentId(userId, options);
833
+ const discoveredTargets = await this.listAccessTargets(userId, platformName, { agentId });
829
834
 
830
835
  const recentRows = db.prepare(
831
836
  `SELECT platform_chat_id, metadata
@@ -0,0 +1,103 @@
1
+ 'use strict';
2
+
3
+ const db = require('../db/database');
4
+ const { getRuntimeValidation } = require('./runtime/validation');
5
+
6
+ function hasAnyAiProviderEnv() {
7
+ return [
8
+ 'OPENAI_API_KEY',
9
+ 'ANTHROPIC_API_KEY',
10
+ 'GOOGLE_API_KEY',
11
+ 'GEMINI_API_KEY',
12
+ 'OPENROUTER_API_KEY',
13
+ ].some((name) => Boolean(String(process.env[name] || '').trim()));
14
+ }
15
+
16
+ function component(group, name, status, description) {
17
+ return { group, name, status, description };
18
+ }
19
+
20
+ function dbIsReachable() {
21
+ try {
22
+ db.prepare('SELECT 1 AS ok').get();
23
+ return true;
24
+ } catch {
25
+ return false;
26
+ }
27
+ }
28
+
29
+ function overallStatus(components) {
30
+ if (components.some((item) => item.status === 'unavailable')) return 'unavailable';
31
+ if (components.some((item) => item.status === 'degraded')) return 'degraded';
32
+ return 'operational';
33
+ }
34
+
35
+ function buildPublicStatus(app) {
36
+ const runtimeValidation = getRuntimeValidation(app?.locals?.runtimeManager);
37
+ const runtimeReady = Boolean(runtimeValidation?.ready);
38
+ const storageReady = dbIsReachable();
39
+ const hasMessaging = Boolean(app?.locals?.messagingManager);
40
+ const hasIntegrations = Boolean(app?.locals?.integrationManager);
41
+ const hasMcp = Boolean(app?.locals?.mcpClient);
42
+ const hasDesktop = Boolean(app?.locals?.desktopCompanionRegistry || app?.locals?.desktopProvider);
43
+ const hasBrowser = Boolean(app?.locals?.browserExtensionRegistry || app?.locals?.browserController);
44
+ const hasMemory = Boolean(app?.locals?.memoryManager);
45
+ const hasAi = hasAnyAiProviderEnv();
46
+
47
+ const components = [
48
+ component('Core', 'Web App & API', 'operational', 'Requests are being served normally. Tiny victory parade withheld for focus.'),
49
+ component('Core', 'Accounts & Sessions', 'operational', 'Sign-in and session plumbing are available.'),
50
+ component(
51
+ 'Automation',
52
+ 'Agent Runtime',
53
+ runtimeReady ? 'operational' : 'degraded',
54
+ runtimeReady ? 'Runtime checks are passing.' : 'Runtime is reachable, but one or more host capabilities need attention.'
55
+ ),
56
+ component(
57
+ 'Automation',
58
+ 'AI Gateway',
59
+ hasAi ? 'operational' : 'degraded',
60
+ hasAi ? 'AI provider routing is configured.' : 'AI routing is available, but no public provider configuration is detected.'
61
+ ),
62
+ component(
63
+ 'Devices',
64
+ 'Browser & Desktop Control',
65
+ hasBrowser && hasDesktop ? 'operational' : 'degraded',
66
+ hasBrowser && hasDesktop ? 'Control gateways are ready.' : 'One control gateway is not currently initialized.'
67
+ ),
68
+ component(
69
+ 'Communication',
70
+ 'Messaging Bridges',
71
+ hasMessaging ? 'operational' : 'degraded',
72
+ hasMessaging ? 'Messaging bridge manager is online.' : 'Messaging bridge manager is not initialized.'
73
+ ),
74
+ component(
75
+ 'Communication',
76
+ 'Integrations & OAuth',
77
+ hasIntegrations && hasMcp ? 'operational' : 'degraded',
78
+ hasIntegrations && hasMcp ? 'Integration and MCP services are available.' : 'Some integration plumbing is not initialized.'
79
+ ),
80
+ component(
81
+ 'Data',
82
+ 'Storage & Memory',
83
+ storageReady && hasMemory ? 'operational' : 'unavailable',
84
+ storageReady && hasMemory ? 'Database and memory services are responding.' : 'Storage or memory services are not responding.'
85
+ ),
86
+ ];
87
+
88
+ const status = overallStatus(components);
89
+ return {
90
+ product: 'NeoAgent',
91
+ status,
92
+ updatedAt: new Date().toISOString(),
93
+ refreshAfterSeconds: 60,
94
+ note: status === 'operational'
95
+ ? 'All systems are behaving. Suspiciously mature of them.'
96
+ : 'A few systems want a moment. We are listening politely.',
97
+ components,
98
+ };
99
+ }
100
+
101
+ module.exports = {
102
+ buildPublicStatus,
103
+ };
@@ -101,6 +101,12 @@ function serializeRuntimeSettingValue(key, value) {
101
101
  }
102
102
 
103
103
  function redactRuntimeSettingValue(key, value) {
104
+ if (/^social_reach_cookies_/i.test(String(key || ''))) {
105
+ return {
106
+ configured: Boolean(value),
107
+ redacted: true,
108
+ };
109
+ }
104
110
  return value;
105
111
  }
106
112
 
@@ -0,0 +1,56 @@
1
+ 'use strict';
2
+
3
+ class SocialReachChannel {
4
+ constructor(definition) {
5
+ this.id = definition.id;
6
+ this.label = definition.label;
7
+ this.tier = definition.tier;
8
+ this.setupKind = definition.setupKind;
9
+ this.hosts = definition.hosts || [];
10
+ this.domains = definition.domains || [];
11
+ }
12
+
13
+ canHandleUrl(url) {
14
+ if (!this.hosts.length) return false;
15
+ let parsed;
16
+ try {
17
+ parsed = new URL(String(url || ''));
18
+ } catch {
19
+ return false;
20
+ }
21
+ const host = parsed.hostname.toLowerCase().replace(/^www\./, '');
22
+ return this.hosts.some((candidate) => {
23
+ const normalized = String(candidate).toLowerCase().replace(/^www\./, '');
24
+ return host === normalized || host.endsWith(`.${normalized}`);
25
+ });
26
+ }
27
+
28
+ async check() {
29
+ return {
30
+ platform: this.id,
31
+ label: this.label,
32
+ ready: true,
33
+ status: 'ok',
34
+ activeBackend: 'node',
35
+ tier: this.tier,
36
+ setupKind: this.setupKind,
37
+ message: 'Ready.',
38
+ };
39
+ }
40
+
41
+ async read() {
42
+ const error = new Error(`${this.label} reading is not implemented.`);
43
+ error.status = 501;
44
+ throw error;
45
+ }
46
+
47
+ async search() {
48
+ const error = new Error(`${this.label} search is not implemented.`);
49
+ error.status = 501;
50
+ throw error;
51
+ }
52
+ }
53
+
54
+ module.exports = {
55
+ SocialReachChannel,
56
+ };
@@ -0,0 +1,75 @@
1
+ 'use strict';
2
+
3
+ const { SocialReachChannel } = require('./base');
4
+ const { getPlatformDefinition } = require('../platforms');
5
+ const { assertHttpUrl, fetchJson, normalizeLimit } = require('../utils');
6
+
7
+ function parseRepoUrl(url) {
8
+ const parsed = assertHttpUrl(url);
9
+ const parts = parsed.pathname.split('/').filter(Boolean);
10
+ if (parsed.hostname.replace(/^www\./, '') !== 'github.com' || parts.length < 2) {
11
+ const error = new Error('A GitHub repository URL is required.');
12
+ error.status = 400;
13
+ throw error;
14
+ }
15
+ return { owner: parts[0], repo: parts[1].replace(/\.git$/, '') };
16
+ }
17
+
18
+ class GithubChannel extends SocialReachChannel {
19
+ constructor() {
20
+ super(getPlatformDefinition('github'));
21
+ }
22
+
23
+ async check() {
24
+ return {
25
+ ...(await super.check()),
26
+ activeBackend: 'github_rest_public',
27
+ message: 'Public GitHub repository read/search is available through the GitHub REST API.',
28
+ };
29
+ }
30
+
31
+ async read({ url }) {
32
+ const { owner, repo } = parseRepoUrl(url);
33
+ const data = await fetchJson(`https://api.github.com/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}`);
34
+ return {
35
+ platform: this.id,
36
+ owner,
37
+ repo,
38
+ title: data.full_name || `${owner}/${repo}`,
39
+ description: data.description || '',
40
+ url: data.html_url || `https://github.com/${owner}/${repo}`,
41
+ stars: data.stargazers_count || 0,
42
+ forks: data.forks_count || 0,
43
+ openIssues: data.open_issues_count || 0,
44
+ language: data.language || null,
45
+ defaultBranch: data.default_branch || null,
46
+ source: 'github_rest_public',
47
+ };
48
+ }
49
+
50
+ async search({ query, limit }) {
51
+ const q = String(query || '').trim();
52
+ if (!q) {
53
+ const error = new Error('query is required.');
54
+ error.status = 400;
55
+ throw error;
56
+ }
57
+ const data = await fetchJson(`https://api.github.com/search/repositories?q=${encodeURIComponent(q)}&per_page=${normalizeLimit(limit, 10, 30)}`);
58
+ return {
59
+ platform: this.id,
60
+ query: q,
61
+ results: (data.items || []).map((item) => ({
62
+ name: item.full_name,
63
+ description: item.description || '',
64
+ url: item.html_url,
65
+ stars: item.stargazers_count || 0,
66
+ language: item.language || null,
67
+ })),
68
+ source: 'github_rest_public',
69
+ };
70
+ }
71
+ }
72
+
73
+ module.exports = {
74
+ GithubChannel,
75
+ };
@@ -0,0 +1,34 @@
1
+ 'use strict';
2
+
3
+ const { GithubChannel } = require('./github');
4
+ const { LinkedinChannel } = require('./linkedin');
5
+ const { RssChannel } = require('./rss');
6
+ const { UnsupportedChannel } = require('./unsupported');
7
+ const { V2exChannel } = require('./v2ex');
8
+ const { WebChannel } = require('./web');
9
+ const { XueqiuChannel } = require('./xueqiu');
10
+ const { YoutubeChannel } = require('./youtube');
11
+
12
+ function createChannels() {
13
+ return [
14
+ new GithubChannel(),
15
+ new YoutubeChannel(),
16
+ new RssChannel(),
17
+ new V2exChannel(),
18
+ new XueqiuChannel(),
19
+ new LinkedinChannel(),
20
+ new UnsupportedChannel('twitter'),
21
+ new UnsupportedChannel('reddit'),
22
+ new UnsupportedChannel('facebook'),
23
+ new UnsupportedChannel('instagram'),
24
+ new UnsupportedChannel('bilibili'),
25
+ new UnsupportedChannel('xiaohongshu'),
26
+ new UnsupportedChannel('xiaoyuzhou'),
27
+ new UnsupportedChannel('exa_search'),
28
+ new WebChannel(),
29
+ ];
30
+ }
31
+
32
+ module.exports = {
33
+ createChannels,
34
+ };
@@ -0,0 +1,34 @@
1
+ 'use strict';
2
+
3
+ const { SocialReachChannel } = require('./base');
4
+ const { getPlatformDefinition } = require('../platforms');
5
+ const { assertHttpUrl, compactText, fetchText } = require('../utils');
6
+
7
+ class LinkedinChannel extends SocialReachChannel {
8
+ constructor() {
9
+ super(getPlatformDefinition('linkedin'));
10
+ }
11
+
12
+ async check() {
13
+ return {
14
+ ...(await super.check()),
15
+ activeBackend: 'jina_reader_public',
16
+ message: 'Public LinkedIn pages can be read through Jina Reader. Logged-in profile/job automation is unavailable in Node-only mode.',
17
+ };
18
+ }
19
+
20
+ async read({ url }) {
21
+ const parsed = assertHttpUrl(url);
22
+ const text = await fetchText(`https://r.jina.ai/${parsed.toString()}`, { headers: { accept: 'text/plain' } });
23
+ return {
24
+ platform: this.id,
25
+ url: parsed.toString(),
26
+ content: compactText(text, 16000),
27
+ source: 'jina_reader_public',
28
+ };
29
+ }
30
+ }
31
+
32
+ module.exports = {
33
+ LinkedinChannel,
34
+ };
@@ -0,0 +1,58 @@
1
+ 'use strict';
2
+
3
+ const cheerio = require('cheerio');
4
+ const { SocialReachChannel } = require('./base');
5
+ const { getPlatformDefinition } = require('../platforms');
6
+ const { assertHttpUrl, compactText, fetchText, normalizeLimit } = require('../utils');
7
+
8
+ function firstText($, item, names) {
9
+ for (const name of names) {
10
+ const value = $(item).find(name).first().text().trim();
11
+ if (value) return value;
12
+ }
13
+ return '';
14
+ }
15
+
16
+ class RssChannel extends SocialReachChannel {
17
+ constructor() {
18
+ super(getPlatformDefinition('rss'));
19
+ }
20
+
21
+ canHandleUrl(url) {
22
+ const text = String(url || '').toLowerCase();
23
+ return /(?:\/feed\b|\/rss\b|\.xml\b|atom)/.test(text);
24
+ }
25
+
26
+ async check() {
27
+ return {
28
+ ...(await super.check()),
29
+ activeBackend: 'node_xml',
30
+ message: 'Reads RSS and Atom feeds with the built-in Node parser.',
31
+ };
32
+ }
33
+
34
+ async read({ url, limit }) {
35
+ const parsed = assertHttpUrl(url);
36
+ const xml = await fetchText(parsed.toString());
37
+ const $ = cheerio.load(xml, { xmlMode: true });
38
+ const entries = $('item, entry').toArray().slice(0, normalizeLimit(limit, 20, 100));
39
+ const title = $('channel > title, feed > title').first().text().trim() || null;
40
+ return {
41
+ platform: this.id,
42
+ url: parsed.toString(),
43
+ title,
44
+ items: entries.map((item) => ({
45
+ title: firstText($, item, ['title']),
46
+ url: firstText($, item, ['link']) || $(item).find('link').first().attr('href') || '',
47
+ summary: compactText(firstText($, item, ['description', 'summary', 'content']), 1200),
48
+ author: firstText($, item, ['author name', 'author', 'dc\\:creator']),
49
+ publishedAt: firstText($, item, ['pubDate', 'published', 'updated']),
50
+ })),
51
+ source: 'rss',
52
+ };
53
+ }
54
+ }
55
+
56
+ module.exports = {
57
+ RssChannel,
58
+ };
@@ -0,0 +1,49 @@
1
+ 'use strict';
2
+
3
+ const { SocialReachChannel } = require('./base');
4
+ const { getPlatformDefinition } = require('../platforms');
5
+
6
+ const MESSAGES = Object.freeze({
7
+ twitter: 'X/Twitter requires brittle authenticated browser/API access. It is listed for setup visibility but not implemented under the Node-only constraint.',
8
+ reddit: 'Reddit search/read currently needs authenticated browser or third-party tooling for reliable access. It is not implemented under the Node-only constraint.',
9
+ facebook: 'Facebook content access requires a logged-in browser session or approved Graph API app. It is not implemented under the Node-only constraint.',
10
+ instagram: 'Instagram content access requires a logged-in browser session or approved Graph API app. It is not implemented under the Node-only constraint.',
11
+ bilibili: 'Bilibili support in Agent-Reach depends on non-Node tooling. It is not implemented under the Node-only constraint.',
12
+ xiaohongshu: 'Xiaohongshu support depends on authenticated browser automation or non-Node tooling. It is not implemented under the Node-only constraint.',
13
+ xiaoyuzhou: 'Xiaoyuzhou transcription needs media download and Whisper-style transcription setup. It is not implemented under the Node-only constraint.',
14
+ exa_search: 'Exa MCP setup depends on external MCP tooling. Use NeoAgent web search or configure an MCP server separately.',
15
+ });
16
+
17
+ class UnsupportedChannel extends SocialReachChannel {
18
+ constructor(platform) {
19
+ super(getPlatformDefinition(platform));
20
+ }
21
+
22
+ async check() {
23
+ return {
24
+ platform: this.id,
25
+ label: this.label,
26
+ ready: false,
27
+ status: 'off',
28
+ activeBackend: null,
29
+ tier: this.tier,
30
+ setupKind: this.setupKind,
31
+ message: MESSAGES[this.id] || `${this.label} is unavailable in Node-only mode.`,
32
+ };
33
+ }
34
+
35
+ async read() {
36
+ const status = await this.check();
37
+ const error = new Error(status.message);
38
+ error.status = 501;
39
+ throw error;
40
+ }
41
+
42
+ async search() {
43
+ return this.read();
44
+ }
45
+ }
46
+
47
+ module.exports = {
48
+ UnsupportedChannel,
49
+ };
@@ -0,0 +1,85 @@
1
+ 'use strict';
2
+
3
+ const { SocialReachChannel } = require('./base');
4
+ const { getPlatformDefinition } = require('../platforms');
5
+ const { assertHttpUrl, fetchJson, normalizeLimit } = require('../utils');
6
+
7
+ function shapeTopic(item = {}) {
8
+ const node = item.node || {};
9
+ const member = item.member || {};
10
+ return {
11
+ id: item.id || null,
12
+ title: item.title || '',
13
+ url: item.url || (item.id ? `https://www.v2ex.com/t/${item.id}` : ''),
14
+ content: item.content || '',
15
+ replies: item.replies || 0,
16
+ nodeName: node.name || '',
17
+ nodeTitle: node.title || '',
18
+ author: member.username || '',
19
+ createdAt: item.created || null,
20
+ };
21
+ }
22
+
23
+ class V2exChannel extends SocialReachChannel {
24
+ constructor() {
25
+ super(getPlatformDefinition('v2ex'));
26
+ }
27
+
28
+ async check() {
29
+ return {
30
+ ...(await super.check()),
31
+ activeBackend: 'v2ex_public_api',
32
+ message: 'Public API supports hot topics, node topics, topic details, and users.',
33
+ };
34
+ }
35
+
36
+ async read({ url, limit }) {
37
+ const parsed = assertHttpUrl(url);
38
+ const match = parsed.pathname.match(/^\/t\/(\d+)/);
39
+ if (!match) {
40
+ return this.search({ query: parsed.searchParams.get('q') || 'hot', limit });
41
+ }
42
+ const topicId = match[1];
43
+ const topicData = await fetchJson(`https://www.v2ex.com/api/topics/show.json?id=${encodeURIComponent(topicId)}`);
44
+ const topic = Array.isArray(topicData) ? topicData[0] || {} : topicData || {};
45
+ const replies = await fetchJson(`https://www.v2ex.com/api/replies/show.json?topic_id=${encodeURIComponent(topicId)}&page=1`)
46
+ .catch(() => []);
47
+ return {
48
+ platform: this.id,
49
+ ...shapeTopic(topic),
50
+ repliesList: (Array.isArray(replies) ? replies : [])
51
+ .slice(0, normalizeLimit(limit, 50, 100))
52
+ .map((reply) => ({
53
+ author: reply.member?.username || '',
54
+ content: reply.content || '',
55
+ createdAt: reply.created || null,
56
+ })),
57
+ source: 'v2ex_public_api',
58
+ };
59
+ }
60
+
61
+ async search({ query, limit }) {
62
+ const normalized = String(query || '').trim().toLowerCase();
63
+ const capped = normalizeLimit(limit, 20, 100);
64
+ if (normalized && normalized !== 'hot') {
65
+ const data = await fetchJson(`https://www.v2ex.com/api/topics/show.json?node_name=${encodeURIComponent(normalized)}&page=1`);
66
+ return {
67
+ platform: this.id,
68
+ query: normalized,
69
+ results: (Array.isArray(data) ? data : []).slice(0, capped).map(shapeTopic),
70
+ source: 'v2ex_public_api',
71
+ };
72
+ }
73
+ const data = await fetchJson('https://www.v2ex.com/api/topics/hot.json');
74
+ return {
75
+ platform: this.id,
76
+ query: 'hot',
77
+ results: (Array.isArray(data) ? data : []).slice(0, capped).map(shapeTopic),
78
+ source: 'v2ex_public_api',
79
+ };
80
+ }
81
+ }
82
+
83
+ module.exports = {
84
+ V2exChannel,
85
+ };
@@ -0,0 +1,40 @@
1
+ 'use strict';
2
+
3
+ const { SocialReachChannel } = require('./base');
4
+ const { getPlatformDefinition } = require('../platforms');
5
+ const { assertHttpUrl, compactText, fetchText } = require('../utils');
6
+
7
+ class WebChannel extends SocialReachChannel {
8
+ constructor() {
9
+ super(getPlatformDefinition('web'));
10
+ }
11
+
12
+ canHandleUrl() {
13
+ return true;
14
+ }
15
+
16
+ async check() {
17
+ return {
18
+ ...(await super.check()),
19
+ activeBackend: 'jina_reader',
20
+ message: 'Reads public web pages through Jina Reader.',
21
+ };
22
+ }
23
+
24
+ async read({ url }) {
25
+ const parsed = assertHttpUrl(url);
26
+ const target = `https://r.jina.ai/${parsed.toString()}`;
27
+ const text = await fetchText(target, { headers: { accept: 'text/plain' } });
28
+ return {
29
+ platform: this.id,
30
+ url: parsed.toString(),
31
+ title: null,
32
+ content: compactText(text, 20000),
33
+ source: 'jina_reader',
34
+ };
35
+ }
36
+ }
37
+
38
+ module.exports = {
39
+ WebChannel,
40
+ };