diff --git a/packages/backend/src/adapters/intl/snov.json b/packages/backend/src/adapters/intl/snov.json index 0db3ad82a..6e431f934 100644 --- a/packages/backend/src/adapters/intl/snov.json +++ b/packages/backend/src/adapters/intl/snov.json @@ -1,65 +1,176 @@ { "slug": "snov", "name": "Snov.io", - "description": "Drive Snov.io (lead enrichment + email finder + outreach) from any AI agent: email finder, verifier, domain search, prospect lists, drip campaigns. 9 tools, OAuth2 client-credentials auth.", - "instructions": "This connector uses the Snov.io API v1 (snov.io/api).\n\n**Setup**:\n1. Sign in to https://app.snov.io → **Account → API**.\n2. Copy the **Client ID** and **Client Secret**.\n3. Run OAuth2 client_credentials flow:\n ```\n POST https://api.snov.io/v1/oauth/access_token\n grant_type=client_credentials&client_id=...&client_secret=...\n ```\n to obtain an access_token. Tokens are valid for 1 hour.\n4. Set `SNOV_ACCESS_TOKEN` to the access token. Refresh externally.\n\n**Authentication**: `Authorization: Bearer ${SNOV_ACCESS_TOKEN}`.\n\n**Credit model**: every find/verify/enrich consumes credits per your plan. `/balance` is free.\n\n**Workflow**: domain search → email finder → email verifier → add prospects to a list → drip campaign.\n\n**Out of scope here**: drip campaign content editing, LinkedIn extension, sender management, deals (Snov.io CRM lite).", + "description": "Drive Snov.io lead enrichment and outreach from your AI: find a person's email from name and company domain, verify emails, list the emails of a domain, manage prospect lists and see drip campaigns and the credit balance. Signs in with the API client ID and secret.", + "instructions": "This connector uses the Snov.io REST API (snov.io/api).\n\n**Setup (about 2 minutes)**:\n1. Sign in to https://app.snov.io and open **Account settings → API** (https://app.snov.io/account/api).\n2. Copy the **API User ID** into `SNOV_CLIENT_ID` and the **API Secret** into `SNOV_CLIENT_SECRET`.\nThe connector exchanges them for an access token at `https://api.snov.io/v1/oauth/access_token` (OAuth 2.0 client credentials) and gets a new one every hour by itself. There is nothing to paste or renew by hand.\n\n**Plans**: the API is fully available on the paid (Sales Suite) plans; on the free plan Snov.io gives test access on request (help@snov.io). A 403 \"you dont have permissions for this action\" on every call means the plan does not include API access; a 401 means the ID or secret is wrong.\n\n**Credits**: `snov_balance`, `snov_get_domain_emails_count`, the prospect lists and the campaigns are free. `snov_email_finder`, `snov_email_verifier`, `snov_email_finder_by_url` and `snov_domain_search` spend credits. Check `snov_balance` before bulk work and do not repeat a search that already returned results.\n\n**Domain search** runs in two steps: `snov_domain_search` starts the search and returns a `task_hash`; `snov_get_domain_search_result` with that hash returns the emails (up to 50 per page). While `status` is `in_progress`, wait a few seconds and ask again. For the next page pass `meta.next` from the result as `next` to `snov_domain_search`. `snov_get_domain_emails_count` tells for free how many emails a domain has before you spend a credit.\n\n**Workflow**: domain emails count → domain search → email finder (name + domain) → email verifier → add the prospect to a list → drip campaign.\n\n**Rate limit**: 60 requests per minute.\n\n**Out of scope here**: editing drip campaign content, the LinkedIn extension, sender accounts, webhooks and deals (Snov.io CRM).", "region": "intl", "category": "enrichment", "icon": "snov", "docsUrl": "https://snov.io/api", - "requiredEnvVars": ["SNOV_ACCESS_TOKEN"], + "requiredEnvVars": [ + "SNOV_CLIENT_ID", + "SNOV_CLIENT_SECRET" + ], + "envVarMeta": { + "SNOV_CLIENT_ID": { + "label": "API User ID", + "kind": "credential", + "help": "Snov.io, Account settings, API tab: the API User ID (used as client_id).", + "link": "https://app.snov.io/account/api", + "pattern": "^\\S+$", + "patternMessage": "Paste the User ID without spaces." + }, + "SNOV_CLIENT_SECRET": { + "label": "API Secret", + "kind": "credential", + "secret": true, + "help": "Same tab: the API Secret (used as client_secret).", + "link": "https://app.snov.io/account/api", + "pattern": "^\\S+$", + "patternMessage": "Paste the API secret without spaces." + } + }, + "verifyHints": { + "401": "Snov.io did not issue a token: check the API User ID and API Secret under Account settings, API.", + "403": "Snov.io refused the call: the plan probably does not include API access (paid plans, or test access on request).", + "rate_limited": "Snov.io allows 60 requests per minute. Wait a moment and try again." + }, + "probe": { + "tool": "snov_balance" + }, "connector": { - "name": "Snov.io v1", + "name": "Snov.io", "type": "REST", "baseUrl": "https://api.snov.io/v1", - "authType": "BEARER_TOKEN", + "authType": "OAUTH2", "authConfig": { - "token": "{{SNOV_ACCESS_TOKEN}}" - } + "grant": "client_credentials", + "clientId": "{{SNOV_CLIENT_ID}}", + "clientSecret": "{{SNOV_CLIENT_SECRET}}", + "tokenUrl": "https://api.snov.io/v1/oauth/access_token", + "tokenAuthMethod": "client_secret_post" + }, + "headers": { + "Accept": "application/json" + }, + "healthcheckPath": "/get-balance" }, "tools": [ { "name": "snov_balance", - "description": "Return remaining credit balance for the account. Free call — use to monitor before bulk operations.", - "parameters": { "type": "object", "properties": {} }, - "endpointMapping": { "method": "POST", "path": "/get-balance" } + "description": "Remaining credit balance of the account, with days until the monthly limit resets and until the subscription ends. Free; use it before bulk lookups.", + "parameters": { + "type": "object", + "properties": {} + }, + "endpointMapping": { + "method": "GET", + "path": "/get-balance" + } }, { - "name": "snov_domain_search", - "description": "Find emails associated with a domain. Returns up to 100 emails per call with name, position, type (personal/generic).", + "name": "snov_get_domain_emails_count", + "description": "How many emails Snov.io knows for a company domain, before spending a credit on snov_domain_search. Free.", "parameters": { "type": "object", "properties": { - "domain": { "type": "string", "description": "Domain to search." }, - "type": { "type": "string", "description": "personal, generic, all." }, - "limit": { "type": "integer", "description": "Max emails (default 100)." }, - "lastId": { "type": "integer", "description": "Cursor — return emails after this ID." } + "domain": { + "type": "string", + "description": "Company domain, e.g. snov.io." + } }, - "required": ["domain"] + "required": [ + "domain" + ] }, "endpointMapping": { "method": "POST", - "path": "/get-domain-emails-with-info", + "path": "/get-domain-emails-count", "bodyEncoding": "form-urlencoded", + "bodyMapping": { + "domain": "$domain" + } + }, + "annotations": { + "readOnlyHint": true + } + }, + { + "name": "snov_domain_search", + "description": "Start a search for the emails of a company domain. Returns a task_hash; read the emails with snov_get_domain_search_result. Costs 1 credit per unique request, so check snov_get_domain_emails_count first.", + "parameters": { + "type": "object", + "properties": { + "domain": { + "type": "string", + "description": "Company domain, e.g. snov.io, without https:// or a path." + }, + "next": { + "type": "string", + "description": "For the next page: meta.next from the previous snov_get_domain_search_result." + } + }, + "required": [ + "domain" + ] + }, + "endpointMapping": { + "method": "POST", + "path": "https://api.snov.io/v2/domain-search/domain-emails/start", "bodyMapping": { "domain": "$domain", - "type": "$type", - "limit": "$limit", - "lastId": "$lastId" + "next": "$next" } } }, + { + "name": "snov_get_domain_search_result", + "description": "Read the emails found by snov_domain_search: up to 50 emails per page, total_count, meta.next for the next page and status (completed or in_progress; when in progress wait a few seconds and ask again). The emails are not verified. Free.", + "parameters": { + "type": "object", + "properties": { + "task_hash": { + "type": "string", + "description": "task_hash returned by snov_domain_search (meta.task_hash)." + } + }, + "required": [ + "task_hash" + ] + }, + "endpointMapping": { + "method": "GET", + "path": "https://api.snov.io/v2/domain-search/domain-emails/result/{task_hash}", + "encodePathParams": true + }, + "annotations": { + "readOnlyHint": true + } + }, { "name": "snov_email_finder", - "description": "Find a person's email by first/last name + domain.", + "description": "Find a person's email from first name, last name and company domain. Spends credits; the answer lists the emails found with their status.", "parameters": { "type": "object", "properties": { - "domain": { "type": "string", "description": "Company domain." }, - "firstName": { "type": "string", "description": "First name." }, - "lastName": { "type": "string", "description": "Last name." } + "domain": { + "type": "string", + "description": "Company domain." + }, + "firstName": { + "type": "string", + "description": "First name." + }, + "lastName": { + "type": "string", + "description": "Last name." + } }, - "required": ["domain", "firstName", "lastName"] + "required": [ + "domain", + "firstName", + "lastName" + ] }, "endpointMapping": { "method": "POST", @@ -74,59 +185,86 @@ }, { "name": "snov_email_verifier", - "description": "Verify a single email deliverability. Returns status (valid/invalid/catchAll/disposable/webmail/unknown) + smtp/mx checks.", + "description": "Check whether one email address can receive mail: valid, invalid, catch-all, disposable or webmail, with the SMTP and MX checks. Spends a credit.", "parameters": { "type": "object", "properties": { - "email": { "type": "string", "description": "Email to verify." } + "email": { + "type": "string", + "description": "Email to verify." + } }, - "required": ["email"] + "required": [ + "email" + ] }, "endpointMapping": { "method": "POST", "path": "/get-emails-verification-status", "bodyEncoding": "form-urlencoded", - "bodyMapping": { "emails[]": "$email" } + "bodyMapping": { + "emails[]": "$email" + } } }, { "name": "snov_email_finder_by_url", - "description": "Find emails from a LinkedIn URL (or other recognized social URL).", + "description": "Find the emails of a person from a LinkedIn profile URL. Spends credits.", "parameters": { "type": "object", "properties": { - "url": { "type": "string", "description": "LinkedIn profile URL." } + "url": { + "type": "string", + "description": "LinkedIn profile URL." + } }, - "required": ["url"] + "required": [ + "url" + ] }, "endpointMapping": { "method": "POST", "path": "/get-emails-by-url", "bodyEncoding": "form-urlencoded", - "bodyMapping": { "url": "$url" } + "bodyMapping": { + "url": "$url" + } } }, { "name": "snov_list_prospect_lists", - "description": "List your prospect lists (saved leads).", - "parameters": { "type": "object", "properties": {} }, - "endpointMapping": { "method": "GET", "path": "/get-user-lists" } + "description": "List the prospect lists of the account (id, name, number of contacts). Use the id with snov_add_prospect_to_list. Free.", + "parameters": { + "type": "object", + "properties": {} + }, + "endpointMapping": { + "method": "GET", + "path": "/get-user-lists" + } }, { "name": "snov_create_prospect_list", - "description": "Create a prospect list.", + "description": "Create a new, empty prospect list and return its id for snov_add_prospect_to_list. Free.", "parameters": { "type": "object", "properties": { - "name": { "type": "string", "description": "List name." } + "name": { + "type": "string", + "description": "List name." + } }, - "required": ["name"] + "required": [ + "name" + ] }, "endpointMapping": { "method": "POST", - "path": "/add-user-list", + "path": "/lists", "bodyEncoding": "form-urlencoded", - "bodyMapping": { "name": "$name" } + "bodyMapping": { + "name": "$name" + } } }, { @@ -135,17 +273,47 @@ "parameters": { "type": "object", "properties": { - "email": { "type": "string", "description": "Prospect email." }, - "fullName": { "type": "string", "description": "Full name." }, - "firstName": { "type": "string", "description": "First name." }, - "lastName": { "type": "string", "description": "Last name." }, - "position": { "type": "string", "description": "Job title." }, - "companyName": { "type": "string", "description": "Company name." }, - "country": { "type": "string", "description": "Country." }, - "listId": { "type": "integer", "description": "Target list ID." }, - "customFields": { "type": "object", "description": "Custom fields object." } + "email": { + "type": "string", + "description": "Prospect email." + }, + "fullName": { + "type": "string", + "description": "Full name." + }, + "firstName": { + "type": "string", + "description": "First name." + }, + "lastName": { + "type": "string", + "description": "Last name." + }, + "position": { + "type": "string", + "description": "Job title." + }, + "companyName": { + "type": "string", + "description": "Company name." + }, + "country": { + "type": "string", + "description": "Country." + }, + "listId": { + "type": "integer", + "description": "Target list ID." + }, + "customFields": { + "type": "object", + "description": "Custom fields object." + } }, - "required": ["email", "listId"] + "required": [ + "email", + "listId" + ] }, "endpointMapping": { "method": "POST", @@ -166,9 +334,15 @@ }, { "name": "snov_list_drip_campaigns", - "description": "List your drip (outreach) campaigns.", - "parameters": { "type": "object", "properties": {} }, - "endpointMapping": { "method": "GET", "path": "/get-user-campaigns" } + "description": "List the drip (email outreach) campaigns: id, name, the prospect list used, status (Active, Paused, Draft) and dates. Free.", + "parameters": { + "type": "object", + "properties": {} + }, + "endpointMapping": { + "method": "GET", + "path": "/get-user-campaigns" + } } ] } diff --git a/packages/backend/src/adapters/intl/snov.live.spec.ts b/packages/backend/src/adapters/intl/snov.live.spec.ts index f85ba12ee..0037c4ce2 100644 --- a/packages/backend/src/adapters/intl/snov.live.spec.ts +++ b/packages/backend/src/adapters/intl/snov.live.spec.ts @@ -1,6 +1,87 @@ import * as adapter from './snov.json'; -const a = adapter as unknown as { connector: { baseUrl: string; authType: string; authConfig: Record } }; -describe('snov adapter — static spec conformance', () => { - it('api.snov.io/v1', () => expect(a.connector.baseUrl).toBe('https://api.snov.io/v1')); - it('Bearer (OAuth2 client-credentials access token)', () => expect(a.connector.authType).toBe('BEARER_TOKEN')); + +/** + * Static checks for the Snov.io adapter, pinned against snov.io/api + * (checked 19 Oct 2026) and the errors a live Cloud connector logged: + * + * - The access token lives one hour. The adapter used to ask for it as a + * variable, so a connector stopped working an hour after setup (401 + * Unauthenticated). It now signs in with the API User ID and Secret + * (OAuth 2.0 client credentials) and renews the token itself. + * - Check user balance is GET; POST answered 403. + * - v1 get-domain-emails-with-info is gone (404); domain search is the v2 + * start + result pair. + * - Creating a list is POST /v1/lists. + */ +type Tool = { + name: string; + description: string; + parameters: { properties?: Record; required?: string[] }; + endpointMapping: { method: string; path: string; bodyMapping?: Record; bodyEncoding?: string }; + annotations?: { readOnlyHint?: boolean }; +}; +const a = adapter as unknown as { + requiredEnvVars: string[]; + envVarMeta: Record; + probe: { tool: string }; + connector: { baseUrl: string; authType: string; authConfig: Record }; + tools: Tool[]; +}; +const tool = (name: string) => { + const t = a.tools.find((x) => x.name === name); + if (!t) throw new Error(`missing tool ${name}`); + return t; +}; + +describe('snov adapter: static spec conformance', () => { + it('api.snov.io/v1 as the base', () => expect(a.connector.baseUrl).toBe('https://api.snov.io/v1')); + + it('signs in with client credentials and renews the hourly token itself', () => { + expect(a.connector.authType).toBe('OAUTH2'); + expect(a.connector.authConfig).toEqual({ + grant: 'client_credentials', + clientId: '{{SNOV_CLIENT_ID}}', + clientSecret: '{{SNOV_CLIENT_SECRET}}', + tokenUrl: 'https://api.snov.io/v1/oauth/access_token', + tokenAuthMethod: 'client_secret_post', + }); + expect(a.requiredEnvVars).toEqual(['SNOV_CLIENT_ID', 'SNOV_CLIENT_SECRET']); + expect(a.envVarMeta.SNOV_CLIENT_SECRET.secret).toBe(true); + expect(JSON.stringify(a)).not.toContain('SNOV_ACCESS_TOKEN'); + }); + + it('balance is a free GET and the probe', () => { + expect(tool('snov_balance').endpointMapping).toEqual({ method: 'GET', path: '/get-balance' }); + expect(a.probe.tool).toBe('snov_balance'); + }); + + it('domain search is the v2 start + result pair', () => { + const start = tool('snov_domain_search'); + expect(start.endpointMapping.method).toBe('POST'); + expect(start.endpointMapping.path).toBe('https://api.snov.io/v2/domain-search/domain-emails/start'); + expect(start.endpointMapping.bodyMapping).toEqual({ domain: '$domain', next: '$next' }); + const result = tool('snov_get_domain_search_result'); + expect(result.endpointMapping.method).toBe('GET'); + expect(result.endpointMapping.path).toBe( + 'https://api.snov.io/v2/domain-search/domain-emails/result/{task_hash}', + ); + expect(result.parameters.required).toEqual(['task_hash']); + expect(JSON.stringify(a.tools)).not.toContain('get-domain-emails-with-info'); + }); + + it('the free count is a read-only POST', () => { + const t = tool('snov_get_domain_emails_count'); + expect(t.endpointMapping).toMatchObject({ method: 'POST', path: '/get-domain-emails-count' }); + expect(t.annotations?.readOnlyHint).toBe(true); + }); + + it('creating a list is POST /lists', () => { + expect(tool('snov_create_prospect_list').endpointMapping).toMatchObject({ method: 'POST', path: '/lists' }); + }); + + it('every tool that spends credits says so', () => { + for (const n of ['snov_domain_search', 'snov_email_finder', 'snov_email_verifier', 'snov_email_finder_by_url']) { + expect(tool(n).description).toMatch(/credit/i); + } + }); }); diff --git a/packages/backend/src/adapters/intl/twelve-data.json b/packages/backend/src/adapters/intl/twelve-data.json index 9d349a15b..0831bc8ff 100644 --- a/packages/backend/src/adapters/intl/twelve-data.json +++ b/packages/backend/src/adapters/intl/twelve-data.json @@ -2,7 +2,7 @@ "slug": "twelve-data", "name": "Twelve Data", "description": "Market data for AI agents from Twelve Data: real-time quotes and prices, historical time series, exchange rates and currency conversion, symbol search, stock, forex and crypto lists, exchanges and market hours, and RSI, MACD, SMA, EMA and Bollinger Bands. 19 read-only tools, API key.", - "instructions": "This connector uses the Twelve Data REST API (`https://api.twelvedata.com`) for market data on stocks, ETFs, forex and cryptocurrencies. All tools only read.\n\n**Setup**:\n1. Create a free account at https://twelvedata.com and open the dashboard's **API Keys** page.\n2. Copy the key into `TWELVE_DATA_API_KEY`. It is sent in the `Authorization: apikey ` header, as Twelve Data recommends, so it never appears in a URL or a log.\n\n**Plans and limits**: the free Basic plan allows **8 API credits per minute and 800 per day**. Most tools cost 1 credit per symbol and call; reference lists (stocks, forex pairs, exchanges, symbol search, market state, earliest timestamp, API usage) cost 1 credit per call. Going over returns HTTP 429; wait for the next minute. Free plans cover US equities, forex and crypto; many non-US exchanges, fundamentals and some data need a paid plan and answer 403 otherwise. `twelve_data_get_api_usage` shows the usage of the last minute and of the day against the plan limits.\n\n**Where to start**: resolve a name into a symbol with `twelve_data_search_symbols` (for example Siemens -> SIE on XETR). Then `twelve_data_get_price` for a number, `twelve_data_get_quote` for change, volume and 52-week range, or `twelve_data_get_time_series` for history. For currencies use `twelve_data_get_exchange_rate` or `twelve_data_convert_currency` with a pair such as EUR/USD.\n\n**Symbols**: stocks use the ticker (AAPL); when a ticker is listed on several exchanges pass `exchange` (name or MIC). Forex and crypto pairs use a slash: EUR/USD, BTC/USD, ETH/BTC.\n\n**Time series and indicators**: `interval` is one of 1min, 5min, 15min, 30min, 45min, 1h, 2h, 4h, 8h, 1day, 1week, 1month. `outputsize` is 1 to 5000 data points (the tools default to 30). With `start_date` and `end_date` the API returns all points in that range. Values come newest first unless `order` is asc. Datetimes are in the exchange's timezone unless `timezone` is set (UTC for forex and crypto). Prices are strings in the JSON; convert before computing. Indicators here default to the close price.\n\n**Errors**: errors come back as `{\"code\": 400, \"message\": \"...\", \"status\": \"error\"}` with the same HTTP status: 400 bad parameter (the message lists valid values), 401 bad key, 403 plan does not include the data, 404 no data for the parameters, 429 credits exhausted.\n\n**Demo key**: Twelve Data documents the key `demo` for trying a few symbols (for example AAPL, EUR/USD, BTC/USD); it is not meant for real use.", + "instructions": "This connector uses the Twelve Data REST API (`https://api.twelvedata.com`) for market data on stocks, ETFs, forex and cryptocurrencies. All tools only read.\n\n**Setup**:\n1. Create a free account at https://twelvedata.com and open the dashboard's **API Keys** page.\n2. Copy the key into `TWELVE_DATA_API_KEY`. It is sent in the `Authorization: apikey ` header, as Twelve Data recommends, so it never appears in a URL or a log.\n\n**Plans and limits**: the free Basic plan allows **8 API credits per minute and 800 per day**. Most tools cost 1 credit per symbol and call; reference lists (stocks, forex pairs, exchanges, symbol search, market state, earliest timestamp, API usage) cost 1 credit per call. Going over returns HTTP 429; wait for the next minute. Free plans cover US equities, forex and crypto; many non-US exchanges, fundamentals and some data need a paid plan and answer 403 otherwise. `twelve_data_get_api_usage` shows the usage of the last minute and of the day against the plan limits.\n\n**Where to start**: resolve a name into a symbol with `twelve_data_search_symbols` (for example Siemens -> SIE on XETR). Then `twelve_data_get_price` for a number, `twelve_data_get_quote` for change, volume and 52-week range, or `twelve_data_get_time_series` for history. For currencies use `twelve_data_get_exchange_rate` or `twelve_data_convert_currency` with a pair such as EUR/USD.\n\n**Symbols**: stocks use the ticker (AAPL); when a ticker is listed on several exchanges pass `exchange` (name or MIC). Forex and crypto pairs use a slash: EUR/USD, BTC/USD, ETH/BTC.\n\n**Time series and indicators**: `interval` is one of 1min, 5min, 15min, 30min, 45min, 1h, 2h, 4h, 8h, 1day, 1week, 1month. `outputsize` is 1 to 5000 data points (the tools default to 30). With `start_date` and `end_date` the API returns all points in that range. Values come newest first unless `order` is asc. Datetimes are in the exchange's timezone unless `timezone` is set (UTC for forex and crypto). Prices are strings in the JSON; convert before computing. Indicators here default to the close price.\n\n**Errors**: errors come back as `{\"code\": 400, \"message\": \"...\", \"status\": \"error\"}` with the same HTTP status: 400 bad parameter (the message lists valid values), 401 bad key, 403 plan does not include the data, 404 no data for the parameters or a symbol outside the plan (\"This symbol is available starting with the Grow plan\"), 429 credits exhausted.\n\n**Indices and CFDs**: index symbols such as NDX (Nasdaq-100) or SPX need a paid plan, and broker CFD names such as NAS100, US100 or US30 are not Twelve Data symbols at all. On the Basic plan use an ETF that tracks the index instead (QQQ for the Nasdaq-100, SPY for the S&P 500, DIA for the Dow Jones) and say so in the answer. When unsure, call `twelve_data_search_symbols` with `show_plan` true, which tells for each match which plan it needs.\n\n**Demo key**: Twelve Data documents the key `demo` for trying a few symbols (for example AAPL, EUR/USD, BTC/USD); it is not meant for real use.", "region": "intl", "category": "finance", "icon": "twelve-data", @@ -62,7 +62,7 @@ "properties": { "symbol": { "type": "string", - "description": "Ticker, for example AAPL, EUR/USD or BTC/USD. Use twelve_data_search_symbols when unsure." + "description": "Ticker, for example AAPL, EUR/USD or BTC/USD. Use twelve_data_search_symbols when unsure. Indices such as NDX need a paid plan and CFD names such as NAS100 or US100 do not exist here; on the Basic plan use an ETF such as QQQ." }, "exchange": { "type": "string", @@ -126,7 +126,7 @@ "properties": { "symbol": { "type": "string", - "description": "Ticker, for example AAPL, EUR/USD or BTC/USD. Use twelve_data_search_symbols when unsure." + "description": "Ticker, for example AAPL, EUR/USD or BTC/USD. Use twelve_data_search_symbols when unsure. Indices such as NDX need a paid plan and CFD names such as NAS100 or US100 do not exist here; on the Basic plan use an ETF such as QQQ." }, "exchange": { "type": "string", @@ -161,7 +161,7 @@ "properties": { "symbol": { "type": "string", - "description": "Ticker, for example AAPL, EUR/USD or BTC/USD. Use twelve_data_search_symbols when unsure." + "description": "Ticker, for example AAPL, EUR/USD or BTC/USD. Use twelve_data_search_symbols when unsure. Indices such as NDX need a paid plan and CFD names such as NAS100 or US100 do not exist here; on the Basic plan use an ETF such as QQQ." }, "interval": { "type": "string", @@ -260,7 +260,7 @@ "properties": { "symbol": { "type": "string", - "description": "Ticker, for example AAPL, EUR/USD or BTC/USD. Use twelve_data_search_symbols when unsure." + "description": "Ticker, for example AAPL, EUR/USD or BTC/USD. Use twelve_data_search_symbols when unsure. Indices such as NDX need a paid plan and CFD names such as NAS100 or US100 do not exist here; on the Basic plan use an ETF such as QQQ." }, "exchange": { "type": "string", @@ -395,6 +395,10 @@ "maximum": 120, "default": 10, "description": "Number of data points, 1 to 120. Default 10 here; keep it small to save context." + }, + "show_plan": { + "type": "boolean", + "description": "true adds, for every match, the plan it needs (access.plan), so you can pick a symbol the account can read." } }, "required": [ @@ -406,7 +410,8 @@ "path": "/symbol_search", "queryParams": { "symbol": "$symbol", - "outputsize": "$outputsize" + "outputsize": "$outputsize", + "show_plan": "$show_plan" } } }, @@ -578,7 +583,7 @@ "properties": { "symbol": { "type": "string", - "description": "Ticker, for example AAPL, EUR/USD or BTC/USD. Use twelve_data_search_symbols when unsure." + "description": "Ticker, for example AAPL, EUR/USD or BTC/USD. Use twelve_data_search_symbols when unsure. Indices such as NDX need a paid plan and CFD names such as NAS100 or US100 do not exist here; on the Basic plan use an ETF such as QQQ." }, "interval": { "type": "string", @@ -662,7 +667,7 @@ "properties": { "symbol": { "type": "string", - "description": "Ticker, for example AAPL, EUR/USD or BTC/USD. Use twelve_data_search_symbols when unsure." + "description": "Ticker, for example AAPL, EUR/USD or BTC/USD. Use twelve_data_search_symbols when unsure. Indices such as NDX need a paid plan and CFD names such as NAS100 or US100 do not exist here; on the Basic plan use an ETF such as QQQ." }, "interval": { "type": "string", @@ -739,7 +744,7 @@ "properties": { "symbol": { "type": "string", - "description": "Ticker, for example AAPL, EUR/USD or BTC/USD. Use twelve_data_search_symbols when unsure." + "description": "Ticker, for example AAPL, EUR/USD or BTC/USD. Use twelve_data_search_symbols when unsure. Indices such as NDX need a paid plan and CFD names such as NAS100 or US100 do not exist here; on the Basic plan use an ETF such as QQQ." }, "interval": { "type": "string", @@ -829,7 +834,7 @@ "properties": { "symbol": { "type": "string", - "description": "Ticker, for example AAPL, EUR/USD or BTC/USD. Use twelve_data_search_symbols when unsure." + "description": "Ticker, for example AAPL, EUR/USD or BTC/USD. Use twelve_data_search_symbols when unsure. Indices such as NDX need a paid plan and CFD names such as NAS100 or US100 do not exist here; on the Basic plan use an ETF such as QQQ." }, "interval": { "type": "string", @@ -906,7 +911,7 @@ "properties": { "symbol": { "type": "string", - "description": "Ticker, for example AAPL, EUR/USD or BTC/USD. Use twelve_data_search_symbols when unsure." + "description": "Ticker, for example AAPL, EUR/USD or BTC/USD. Use twelve_data_search_symbols when unsure. Indices such as NDX need a paid plan and CFD names such as NAS100 or US100 do not exist here; on the Basic plan use an ETF such as QQQ." }, "interval": { "type": "string", @@ -983,7 +988,7 @@ "properties": { "symbol": { "type": "string", - "description": "Ticker, for example AAPL, EUR/USD or BTC/USD. Use twelve_data_search_symbols when unsure." + "description": "Ticker, for example AAPL, EUR/USD or BTC/USD. Use twelve_data_search_symbols when unsure. Indices such as NDX need a paid plan and CFD names such as NAS100 or US100 do not exist here; on the Basic plan use an ETF such as QQQ." }, "interval": { "type": "string", diff --git a/packages/backend/src/adapters/intl/twelve-data.live.spec.ts b/packages/backend/src/adapters/intl/twelve-data.live.spec.ts index 341d068f0..bd93472e5 100644 --- a/packages/backend/src/adapters/intl/twelve-data.live.spec.ts +++ b/packages/backend/src/adapters/intl/twelve-data.live.spec.ts @@ -37,6 +37,17 @@ describe('twelve-data adapter: static spec conformance', () => { } }); + it('steers index and CFD names to a symbol the plan can read', () => { + // Cloud log, 17 Oct 2026: NDX answered "available starting with the Pro + // plan" (404), US100 "Grow plan", NAS100 "symbol missing or invalid". + const a2 = a as unknown as { instructions: string }; + expect(a2.instructions).toMatch(/NAS100/); + expect(a2.instructions).toMatch(/QQQ/); + const symbol = tool('twelve_data_get_time_series').parameters.properties!.symbol as { description: string }; + expect(symbol.description).toMatch(/QQQ/); + expect(tool('twelve_data_search_symbols').endpointMapping.queryParams).toMatchObject({ show_plan: '$show_plan' }); + }); + it('time series and indicators require symbol and interval', () => { for (const name of ['twelve_data_get_time_series', 'twelve_data_get_rsi', 'twelve_data_get_macd', 'twelve_data_get_bbands']) { expect(tool(name).parameters.required).toEqual(['symbol', 'interval']); diff --git a/packages/backend/src/adapters/intl/uzum-market.json b/packages/backend/src/adapters/intl/uzum-market.json index fd321f627..b9c9615ce 100644 --- a/packages/backend/src/adapters/intl/uzum-market.json +++ b/packages/backend/src/adapters/intl/uzum-market.json @@ -2,7 +2,7 @@ "slug": "uzum-market", "name": "Uzum Market", "description": "Manage an Uzum Market (Uzbekistan) seller account from an AI agent: shops, products and SKUs, FBS and DBS orders, stock, prices, sales and seller expenses, supply and return invoices. 16 tools on the official seller OpenAPI, one API token.", - "instructions": "This connector uses the Uzum Market seller OpenAPI (`https://api-seller.uzum.uz/api/seller-openapi`).\n\n**Setup**:\n1. Sign in to the Uzum Market seller cabinet (https://seller.uzum.uz) with the account that owns the shops.\n2. Open the settings and create an API token in the API section.\n3. Copy the token into `UZUM_API_TOKEN`.\n\n**Authentication**: the token goes into the `Authorization` header as is, without a `Bearer` prefix (Uzum documents it this way). A 401 or 403 means the token is wrong, revoked, or belongs to another seller; Uzum answers a malformed token with a 500.\n\n**Network**: Uzum's API gateway answers 400 to every request from AnythingMCP Cloud's servers, so the tools are routed through AnythingMCP's outbound proxy. Self-hosted instances call Uzum directly unless `CONNECTOR_PROXY_URL` is set; if your server gets a 400 HTML page for every call, set it.\n\n**Where to start**: `uzum_market_list_shops` returns the shops the token can see. Almost every other tool needs a `shop_id` (or `shop_ids`) from it. Then:\n- products and SKUs with their stock and statuses: `uzum_market_list_products`;\n- new orders to process: `uzum_market_list_orders` with status `CREATED` (default), then `uzum_market_confirm_order` or `uzum_market_cancel_order`;\n- stock for FBS and DBS: `uzum_market_list_stocks`, then `uzum_market_update_stocks`;\n- money: `uzum_market_list_sales` (sold items with commission and seller profit) and `uzum_market_list_expenses` (fees and services charged).\n\n**Dates** in filters (`date_from`, `date_to`) are Unix timestamps in milliseconds, for example 1727427283895. Dates in answers are ISO strings or millisecond timestamps.\n\n**Pagination**: page numbers start at 0. Orders and returns allow at most 50 per page, FBS invoices 20, stock 100. `uzum_market_list_stocks` also has a cursor mode: pass `sku_id_from` = 0, then the skuId of the last item, until the list is empty; it is the stable way to read a whole catalogue.\n\n**Order statuses**: CREATED (new, to confirm), PACKING, PENDING_DELIVERY, DELIVERING, DELIVERED, ACCEPTED_AT_DP, DELIVERED_TO_CUSTOMER_DELIVERY_POINT, COMPLETED, CANCELED, PENDING_CANCELLATION, RETURNED. Orders have to be confirmed before their acceptUntil time, otherwise Uzum cancels them.\n\n**Personal data**: DBS orders carry the buyer's name, phone and address in `deliveryInfo`. Only pass them on where the task needs them.\n\n**Rate limits**: Uzum enforces a per-second and a per-day request budget per token and reports it in the response headers `X-RateLimit-Remaining`, `X-RateLimit-Burst-Capacity`, `X-RateLimit-Limit-Per-Day` and `X-RateLimit-Remaining-Per-Day`. Read pages one at a time and back off on HTTP 429.\n\n**Writes**: `uzum_market_update_stocks` sets absolute FBS/DBS stock per SKU (identified by its barcode), `uzum_market_update_prices` changes full and sale prices of a product's SKUs, `uzum_market_confirm_order` and `uzum_market_cancel_order` change an order. All act on the live shop.", + "instructions": "This connector uses the Uzum Market seller OpenAPI (`https://api-seller.uzum.uz/api/seller-openapi`).\n\n**Setup**:\n1. Sign in to the Uzum Market seller cabinet (https://seller.uzum.uz) with the account that owns the shops.\n2. Open the settings and create an API token in the API section.\n3. Copy the token into `UZUM_API_TOKEN`.\n\n**Authentication**: the token goes into the `Authorization` header as is, without a `Bearer` prefix (Uzum documents it this way). A 401 or 403 means the token is wrong, revoked, or belongs to another seller; Uzum answers a malformed token with a 500.\n\n**Network**: Uzum's API gateway answers 400 to every request from AnythingMCP Cloud's servers, so the tools are routed through AnythingMCP's outbound proxy. Self-hosted instances call Uzum directly unless `CONNECTOR_PROXY_URL` is set; if your server gets a 400 HTML page for every call, set it.\n\n**Where to start**: `uzum_market_list_shops` returns the shops the token can see. Almost every other tool needs a `shop_id` (or `shop_ids`) from it. `uzum_market_list_shops` can also list shops the token is not allowed to read: for such a shop every call answers HTTP 500 with a \"Whitelabel Error Page\", and so does any call with several `shop_ids` that include it. When that happens, try the shops one at a time, keep working with the ones that answer and tell the user which shop failed: the seller cabinet user that created the token needs access to that shop (or a new token from an account that has it). Then:\n- products and SKUs with their stock and statuses: `uzum_market_list_products`;\n- new orders to process: `uzum_market_list_orders` with status `CREATED` (default), then `uzum_market_confirm_order` or `uzum_market_cancel_order`;\n- stock for FBS and DBS: `uzum_market_list_stocks`, then `uzum_market_update_stocks`;\n- money: `uzum_market_list_sales` (sold items with commission and seller profit) and `uzum_market_list_expenses` (fees and services charged).\n\n**Dates** in filters (`date_from`, `date_to`) are Unix timestamps in milliseconds, for example 1727427283895. Dates in answers are ISO strings or millisecond timestamps.\n\n**Pagination**: page numbers start at 0. Orders and returns allow at most 50 per page, FBS invoices 20, stock 100. `uzum_market_list_stocks` also has a cursor mode: pass `sku_id_from` = 0, then the skuId of the last item, until the list is empty; it is the stable way to read a whole catalogue.\n\n**Order statuses**: CREATED (new, to confirm), PACKING, PENDING_DELIVERY, DELIVERING, DELIVERED, ACCEPTED_AT_DP, DELIVERED_TO_CUSTOMER_DELIVERY_POINT, COMPLETED, CANCELED, PENDING_CANCELLATION, RETURNED. Orders have to be confirmed before their acceptUntil time, otherwise Uzum cancels them.\n\n**Personal data**: DBS orders carry the buyer's name, phone and address in `deliveryInfo`. Only pass them on where the task needs them.\n\n**Rate limits**: Uzum enforces a per-second and a per-day request budget per token and reports it in the response headers `X-RateLimit-Remaining`, `X-RateLimit-Burst-Capacity`, `X-RateLimit-Limit-Per-Day` and `X-RateLimit-Remaining-Per-Day`. Read pages one at a time and back off on HTTP 429.\n\n**Writes**: `uzum_market_update_stocks` sets absolute FBS/DBS stock per SKU (identified by its barcode), `uzum_market_update_prices` changes full and sale prices of a product's SKUs, `uzum_market_confirm_order` and `uzum_market_cancel_order` change an order. All act on the live shop.", "region": "intl", "category": "e-commerce", "icon": "uzum-market", @@ -45,7 +45,7 @@ "tools": [ { "name": "uzum_market_list_shops", - "description": "List the seller's own shops (id and name). Call this first: the shop id is needed by the product, order, price, sales and invoice tools.", + "description": "List the seller's own shops (id and name). Call this first: the shop id is needed by the product, order, price, sales and invoice tools. A shop listed here can still answer 500 on every other tool when the token has no rights on it; then query the shops one at a time.", "useProxy": true, "parameters": { "type": "object", diff --git a/packages/backend/src/adapters/intl/uzum-market.live.spec.ts b/packages/backend/src/adapters/intl/uzum-market.live.spec.ts index 8dbf88ea6..e4e992048 100644 --- a/packages/backend/src/adapters/intl/uzum-market.live.spec.ts +++ b/packages/backend/src/adapters/intl/uzum-market.live.spec.ts @@ -31,6 +31,15 @@ describe('uzum-market adapter: static spec conformance', () => { expect(tool('uzum_market_list_shops').parameters.required ?? []).toEqual([]); }); + it('explains the 500 a shop answers when the token has no rights on it', () => { + // Live, 19 Oct 2026: a token listed four shops; one answered every call, + // three answered 500 Whitelabel Error Page on every call, and so did any + // multi-shop call that included them. + const a2 = a as unknown as { instructions: string }; + expect(a2.instructions).toMatch(/Whitelabel Error Page/); + expect(tool('uzum_market_list_shops').description).toMatch(/one at a time/); + }); + it('uses the current versions of the order and stock endpoints', () => { expect(tool('uzum_market_list_orders').endpointMapping.path).toBe('/v2/fbs/orders'); expect(tool('uzum_market_list_stocks').endpointMapping.path).toBe('/v3/fbs/sku/stocks'); diff --git a/packages/backend/src/adapters/intl/worksection.json b/packages/backend/src/adapters/intl/worksection.json index 2127c44ac..a56d85e19 100644 --- a/packages/backend/src/adapters/intl/worksection.json +++ b/packages/backend/src/adapters/intl/worksection.json @@ -51,6 +51,7 @@ "clientSecret": "{{WORKSECTION_CLIENT_SECRET}}", "authorizationUrl": "https://worksection.com/oauth2/authorize", "tokenUrl": "https://worksection.com/oauth2/token", + "refreshUrl": "https://worksection.com/oauth2/refresh", "scopes": "projects_read projects_write tasks_read tasks_write comments_read comments_write costs_read costs_write tags_read tags_write files_read users_read contacts_read" } }, diff --git a/packages/backend/src/adapters/intl/worksection.live.spec.ts b/packages/backend/src/adapters/intl/worksection.live.spec.ts index 29b772319..940cb945d 100644 --- a/packages/backend/src/adapters/intl/worksection.live.spec.ts +++ b/packages/backend/src/adapters/intl/worksection.live.spec.ts @@ -32,6 +32,10 @@ describe('worksection adapter: static spec conformance', () => { }); }); + it('renews at /oauth2/refresh: /oauth2/token answers a refresh without an access_token', () => { + expect(a.connector.authConfig.refreshUrl).toBe('https://worksection.com/oauth2/refresh'); + }); + it('every tool calls /api/oauth2 with a fixed action', () => { for (const t of a.tools) { expect(t.endpointMapping.path).toBe('/api/oauth2'); diff --git a/packages/backend/src/adapters/jp/line-messaging.json b/packages/backend/src/adapters/jp/line-messaging.json index 5a6b49e3b..d3368d07f 100644 --- a/packages/backend/src/adapters/jp/line-messaging.json +++ b/packages/backend/src/adapters/jp/line-messaging.json @@ -1,13 +1,15 @@ { "slug": "line-messaging", "name": "LINE Messaging API", - "description": "Send push, reply and broadcast messages from a LINE Official Account — LINE is the dominant chat platform in Japan, Taiwan and Thailand. Free tier of 500 push messages per month, unlimited reply messages, channel access tokens require no KYC.", - "instructions": "This connector uses the LINE Messaging API v2.\n\n**Creating a channel**: log in to https://developers.line.biz/console → create a Provider → 'Create a new channel' → 'Messaging API'. Set up the LINE Official Account (auto-created with the channel), then under the channel's 'Messaging API' tab, scroll to 'Channel access token' → 'Issue' a long-lived token. Paste it as `LINE_CHANNEL_ACCESS_TOKEN`.\n\n**Authentication**: Bearer Token (the channel access token). Note: this token belongs to the channel, not to a user — anyone with the token can post as that Official Account, so keep it secret.\n\n**Message shape**: LINE messages are objects with a `type` field. Simplest example: `{\"type\": \"text\", \"text\": \"Hello\"}`. Other types: `sticker`, `image`, `video`, `audio`, `location`, `template`, `flex`. The `messages` parameter on every tool is an **array of up to 5** message objects.\n\n**`to` userId**: cannot be looked up cold — you only get a userId from webhook events when a user interacts with the Official Account. Store the userId on first webhook contact, then use it for push.\n\n**`replyToken`**: returned in webhook events. Valid for **~30 seconds** after the original user message. Use `line_reply_message` (free, unlimited) instead of `line_push_message` (counts against the 500/month free quota) whenever possible.\n\n**Broadcast**: sends to ALL friends of the Official Account. Counts heavily against the push quota — use sparingly.\n\n**Pricing**: free tier = 500 push messages/month. Paid tiers from JP¥5,000/month for higher quotas.", + "description": "Send push, reply and broadcast messages from a LINE Official Account, the main chat app in Japan, Taiwan and Thailand, and check the bot, its monthly message quota and user profiles. The free plan includes a monthly number of push messages (it varies by country); replies are free.", + "instructions": "This connector uses the LINE Messaging API v2.\n\n**Creating a channel**: log in to https://developers.line.biz/console → create a Provider → 'Create a new channel' → 'Messaging API'. Set up the LINE Official Account (auto-created with the channel), then under the channel's 'Messaging API' tab, scroll to 'Channel access token' → 'Issue' a long-lived token. Paste it as `LINE_CHANNEL_ACCESS_TOKEN`.\n\n**Authentication**: Bearer Token (the channel access token). Note: this token belongs to the channel, not to a user — anyone with the token can post as that Official Account, so keep it secret.\n\n**Message shape**: LINE messages are objects with a `type` field. Simplest example: `{\"type\": \"text\", \"text\": \"Hello\"}`. Other types: `sticker`, `image`, `video`, `audio`, `location`, `template`, `flex`. The `messages` parameter on every tool is an **array of up to 5** message objects.\n\n**`to` userId**: a userId is `U` followed by 32 hex characters. It comes from a webhook event (this connector does not receive webhooks, so the user has to give it to you) or, on verified and premium accounts, from `line_list_follower_ids`. A display name is not a userId, and a made-up one fails with \"The value for the 'userId' parameter is invalid\".\n\n**`replyToken`**: returned in webhook events, single-use, valid for about a minute after the user's message. Never call `line_reply_message` with a placeholder: LINE answers \"Invalid reply token\". Without a fresh token, push instead. Use `line_reply_message` (free, unlimited) instead of `line_push_message` (counts against the monthly quota) whenever possible.\n\n**Broadcast**: sends to ALL friends of the Official Account. Counts heavily against the push quota — use sparingly.\n\n**Pricing**: the free plan includes a monthly number of push and broadcast messages that depends on the country (for example 200 in Japan); paid plans raise it. `line_get_message_quota` returns the account's own limit.\n\n**Before sending**: `line_get_bot_info` checks the token, `line_get_message_quota` and `line_get_message_quota_consumption` show how much of the monthly push quota is left. Push and broadcast reach real people: show the message and the recipient and wait for the user's yes.", "region": "jp", "category": "messaging", "icon": "line", "docsUrl": "https://developers.line.biz/en/reference/messaging-api/", - "requiredEnvVars": ["LINE_CHANNEL_ACCESS_TOKEN"], + "requiredEnvVars": [ + "LINE_CHANNEL_ACCESS_TOKEN" + ], "connector": { "name": "LINE Messaging API", "type": "REST", @@ -18,26 +20,80 @@ } }, "tools": [ + { + "name": "line_get_bot_info", + "description": "Basic information about the Official Account behind the token: its bot userId, basic id (@...), display name, picture, chat mode and whether messages are marked as read automatically. Free; a good first call to check the token.", + "parameters": { + "type": "object", + "properties": {} + }, + "endpointMapping": { + "method": "GET", + "path": "/v2/bot/info" + } + }, { "name": "line_push_message", - "description": "Send a push message to a specific userId, groupId, or roomId. Counts against the monthly push quota (500/month free). Use line_reply_message instead whenever you have a fresh replyToken.", + "description": "Send a push message to a specific userId, groupId, or roomId. Counts against the monthly message quota (line_get_message_quota). Use line_reply_message instead whenever you have a fresh replyToken.", "parameters": { "type": "object", "properties": { "to": { "type": "string", - "description": "Destination — userId (from a webhook event), groupId, or roomId. Cannot be looked up; obtain from webhook." + "description": "Destination: a userId (U followed by 32 hex characters), groupId (C...) or roomId (R...) from a webhook event or from line_list_follower_ids. Never a display name, and never made up." }, "messages": { "type": "array", - "description": "Array of 1–5 LINE message objects. Simplest text: [{\"type\": \"text\", \"text\": \"Hello\"}]. See https://developers.line.biz/en/reference/messaging-api/#message-objects for full schema." + "description": "Array of 1–5 LINE message objects. Simplest text: [{\"type\": \"text\", \"text\": \"Hello\"}]. See https://developers.line.biz/en/reference/messaging-api/#message-objects for full schema.", + "items": { + "type": "object", + "description": "One LINE message object. Every message needs a type; a text message is {\"type\": \"text\", \"text\": \"Hello\"}.", + "properties": { + "type": { + "type": "string", + "enum": [ + "text", + "textV2", + "sticker", + "image", + "video", + "audio", + "location", + "imagemap", + "template", + "flex" + ], + "description": "Message type. Required on every message." + }, + "text": { + "type": "string", + "description": "For type text: the text, up to 5000 characters." + }, + "altText": { + "type": "string", + "description": "For template, imagemap and flex: the text shown in notifications and chat lists. Required for those types." + }, + "contents": { + "type": "object", + "description": "For flex: a bubble or carousel container ({\"type\": \"bubble\", \"body\": {\"type\": \"box\", \"layout\": \"vertical\", \"contents\": [...]}}). Never empty." + } + }, + "required": [ + "type" + ] + }, + "minItems": 1, + "maxItems": 5 }, "notificationDisabled": { "type": "boolean", "description": "If true, the user is not notified (silent push). Default false." } }, - "required": ["to", "messages"] + "required": [ + "to", + "messages" + ] }, "endpointMapping": { "method": "POST", @@ -51,24 +107,66 @@ }, { "name": "line_reply_message", - "description": "Reply to a webhook-received message using its replyToken. FREE and unlimited (does not count against the push quota). The replyToken is valid for ~30 seconds after the user's message — call this synchronously from your webhook handler.", + "description": "Reply to a message a user just sent, using the replyToken from that webhook event. Free and unlimited (does not count against the push quota). The replyToken is single-use and expires about a minute after the user's message; this connector receives no webhooks, so only call this with a real token the user gave you. Never make one up: without a token use line_push_message.", "parameters": { "type": "object", "properties": { "replyToken": { "type": "string", - "description": "The replyToken from a webhook event. Single-use, expires after ~30 seconds." + "description": "The replyToken of a webhook event, exactly as received. Single-use and short-lived. Do not invent or use a placeholder." }, "messages": { "type": "array", - "description": "Array of 1–5 LINE message objects." + "description": "Array of 1–5 LINE message objects.", + "items": { + "type": "object", + "description": "One LINE message object. Every message needs a type; a text message is {\"type\": \"text\", \"text\": \"Hello\"}.", + "properties": { + "type": { + "type": "string", + "enum": [ + "text", + "textV2", + "sticker", + "image", + "video", + "audio", + "location", + "imagemap", + "template", + "flex" + ], + "description": "Message type. Required on every message." + }, + "text": { + "type": "string", + "description": "For type text: the text, up to 5000 characters." + }, + "altText": { + "type": "string", + "description": "For template, imagemap and flex: the text shown in notifications and chat lists. Required for those types." + }, + "contents": { + "type": "object", + "description": "For flex: a bubble or carousel container ({\"type\": \"bubble\", \"body\": {\"type\": \"box\", \"layout\": \"vertical\", \"contents\": [...]}}). Never empty." + } + }, + "required": [ + "type" + ] + }, + "minItems": 1, + "maxItems": 5 }, "notificationDisabled": { "type": "boolean", "description": "If true, the user is not notified (silent reply). Default false." } }, - "required": ["replyToken", "messages"] + "required": [ + "replyToken", + "messages" + ] }, "endpointMapping": { "method": "POST", @@ -80,18 +178,87 @@ } } }, + { + "name": "line_broadcast_message", + "description": "Broadcast a message to ALL friends of the Official Account. Heavy on the monthly push quota (1 message × N friends = N counted pushes). Use sparingly. Returns 202 Accepted; delivery happens asynchronously.", + "parameters": { + "type": "object", + "properties": { + "messages": { + "type": "array", + "description": "Array of 1–5 LINE message objects to broadcast.", + "items": { + "type": "object", + "description": "One LINE message object. Every message needs a type; a text message is {\"type\": \"text\", \"text\": \"Hello\"}.", + "properties": { + "type": { + "type": "string", + "enum": [ + "text", + "textV2", + "sticker", + "image", + "video", + "audio", + "location", + "imagemap", + "template", + "flex" + ], + "description": "Message type. Required on every message." + }, + "text": { + "type": "string", + "description": "For type text: the text, up to 5000 characters." + }, + "altText": { + "type": "string", + "description": "For template, imagemap and flex: the text shown in notifications and chat lists. Required for those types." + }, + "contents": { + "type": "object", + "description": "For flex: a bubble or carousel container ({\"type\": \"bubble\", \"body\": {\"type\": \"box\", \"layout\": \"vertical\", \"contents\": [...]}}). Never empty." + } + }, + "required": [ + "type" + ] + }, + "minItems": 1, + "maxItems": 5 + }, + "notificationDisabled": { + "type": "boolean", + "description": "If true, recipients are not notified. Default false." + } + }, + "required": [ + "messages" + ] + }, + "endpointMapping": { + "method": "POST", + "path": "/v2/bot/message/broadcast", + "bodyMapping": { + "messages": "$messages", + "notificationDisabled": "$notificationDisabled" + } + } + }, { "name": "line_get_profile", - "description": "Get a user's display name, profile picture URL, status message and BCP-47 language tag. Only works for users who have added the Official Account as a friend.", + "description": "Get a user's display name, profile picture URL, status message and language. Only for users who added the Official Account as a friend. Needs a real userId (U followed by 32 hex characters) from a webhook event or line_list_follower_ids.", "parameters": { "type": "object", "properties": { "userId": { "type": "string", - "description": "LINE userId (from a webhook event)." + "description": "LINE userId, U followed by 32 hex characters, from a webhook event or line_list_follower_ids. Not a display name; do not guess one." } }, - "required": ["userId"] + "required": [ + "userId" + ] }, "endpointMapping": { "method": "GET", @@ -99,28 +266,54 @@ } }, { - "name": "line_broadcast_message", - "description": "Broadcast a message to ALL friends of the Official Account. Heavy on the monthly push quota (1 message × N friends = N counted pushes). Use sparingly. Returns 202 Accepted; delivery happens asynchronously.", + "name": "line_get_message_quota", + "description": "The monthly limit of push and broadcast messages of the account: type (none for unlimited, limited) and value, the number of messages per month.", + "parameters": { + "type": "object", + "properties": {} + }, + "endpointMapping": { + "method": "GET", + "path": "/v2/bot/message/quota" + } + }, + { + "name": "line_get_message_quota_consumption", + "description": "How many push and broadcast messages the account has sent this month (totalUsage). Compare with line_get_message_quota before a push or broadcast. Reply messages are not counted.", + "parameters": { + "type": "object", + "properties": {} + }, + "endpointMapping": { + "method": "GET", + "path": "/v2/bot/message/quota/consumption" + } + }, + { + "name": "line_list_follower_ids", + "description": "List the userIds of the users who added the Official Account as a friend, up to 1000 per page, to use with line_get_profile or line_push_message. LINE only offers this to verified and premium accounts; other accounts get 403. Users who blocked the account or have not agreed to share their profile are left out.", "parameters": { "type": "object", "properties": { - "messages": { - "type": "array", - "description": "Array of 1–5 LINE message objects to broadcast." + "limit": { + "type": "integer", + "minimum": 1, + "maximum": 1000, + "default": 300, + "description": "UserIds per page, at most 1000." }, - "notificationDisabled": { - "type": "boolean", - "description": "If true, recipients are not notified. Default false." + "start": { + "type": "string", + "description": "Continuation token: the next value from the previous page." } - }, - "required": ["messages"] + } }, "endpointMapping": { - "method": "POST", - "path": "/v2/bot/message/broadcast", - "bodyMapping": { - "messages": "$messages", - "notificationDisabled": "$notificationDisabled" + "method": "GET", + "path": "/v2/bot/followers/ids", + "queryParams": { + "limit": "$limit", + "start": "$start" } } } diff --git a/packages/backend/src/adapters/jp/line-messaging.live.spec.ts b/packages/backend/src/adapters/jp/line-messaging.live.spec.ts new file mode 100644 index 000000000..fa49b757c --- /dev/null +++ b/packages/backend/src/adapters/jp/line-messaging.live.spec.ts @@ -0,0 +1,73 @@ +import * as adapter from './line-messaging.json'; + +/** + * Static checks for the LINE Messaging API adapter. The Cloud log of one + * connector (8 Oct 2026) showed every error was a value the model made up: + * a placeholder replyToken ("REPLACE_WITH_REAL_TOKEN", "dummy"), userId + * "dummy", a message without `type`, an empty `messages` array. The tools now + * describe the message object (type required, 1 to 5 per call), say where a + * real userId and replyToken come from, and add read tools for the bot, the + * monthly quota and the follower ids. The read tools were run against a live + * channel on 19 Oct 2026: bot info and both quota calls answered 200, the + * follower ids 403 on an unverified account, as LINE documents. + */ +type Prop = { type?: string; items?: { required?: string[]; properties?: Record }; minItems?: number; maxItems?: number; description?: string }; +type Tool = { + name: string; + description: string; + parameters: { properties?: Record; required?: string[] }; + endpointMapping: { method: string; path: string; queryParams?: Record }; +}; +const a = adapter as unknown as { + instructions: string; + connector: { baseUrl: string; authType: string; authConfig: Record }; + tools: Tool[]; +}; +const tool = (name: string) => { + const t = a.tools.find((x) => x.name === name); + if (!t) throw new Error(`missing tool ${name}`); + return t; +}; + +describe('line-messaging adapter: static spec conformance', () => { + it('channel access token as Bearer against api.line.me', () => { + expect(a.connector.baseUrl).toBe('https://api.line.me'); + expect(a.connector.authType).toBe('BEARER_TOKEN'); + expect(a.connector.authConfig.token).toBe('{{LINE_CHANNEL_ACCESS_TOKEN}}'); + }); + + it.each(['line_push_message', 'line_reply_message', 'line_broadcast_message'])( + '%s: 1 to 5 message objects, each with a type', + (name) => { + const messages = tool(name).parameters.properties!.messages; + expect(messages.type).toBe('array'); + expect(messages.minItems).toBe(1); + expect(messages.maxItems).toBe(5); + expect(messages.items?.required).toEqual(['type']); + }, + ); + + it('reply and profile say not to invent tokens or userIds', () => { + expect(tool('line_reply_message').parameters.properties!.replyToken.description).toMatch(/not invent/i); + expect(tool('line_get_profile').parameters.properties!.userId.description).toMatch(/U followed by 32 hex/); + expect(a.instructions).toMatch(/placeholder/); + }); + + it.each([ + ['line_get_bot_info', '/v2/bot/info'], + ['line_get_message_quota', '/v2/bot/message/quota'], + ['line_get_message_quota_consumption', '/v2/bot/message/quota/consumption'], + ['line_list_follower_ids', '/v2/bot/followers/ids'], + ['line_get_profile', '/v2/bot/profile/{userId}'], + ])('%s is a GET on %s', (name, path) => { + expect(tool(name).endpointMapping).toMatchObject({ method: 'GET', path }); + }); + + it('follower ids page with limit and start', () => { + expect(tool('line_list_follower_ids').endpointMapping.queryParams).toEqual({ limit: '$limit', start: '$start' }); + }); + + it('no hard-coded free quota: it differs by country', () => { + expect(JSON.stringify(a)).not.toMatch(/500\/month|500 push/); + }); +}); diff --git a/packages/backend/src/connectors/engines/oauth2-token.service.spec.ts b/packages/backend/src/connectors/engines/oauth2-token.service.spec.ts index 3882cfe41..53cf55c1e 100644 --- a/packages/backend/src/connectors/engines/oauth2-token.service.spec.ts +++ b/packages/backend/src/connectors/engines/oauth2-token.service.spec.ts @@ -219,6 +219,42 @@ describe('OAuth2TokenService', () => { expect(postedBody).toContain('client_secret=client-secret'); }); + it('renews at refreshUrl when the provider has a separate refresh endpoint (Worksection)', async () => { + mockedAxios.post.mockResolvedValue({ + data: { access_token: 'ws-at', refresh_token: 'ws-rt2', expires_in: 86400 }, + }); + + const result = await service.refreshToken({ + tokenUrl: 'https://worksection.com/oauth2/token', + refreshUrl: 'https://worksection.com/oauth2/refresh', + refreshToken: 'ws-rt', + clientId: 'client-id', + clientSecret: 'client-secret', + }); + + expect(result).toBe('ws-at'); + const [url, body] = mockedAxios.post.mock.calls[0] as any; + expect(url).toBe('https://worksection.com/oauth2/refresh'); + expect(body).toContain('grant_type=refresh_token'); + expect(body).toContain('refresh_token=ws-rt'); + expect(body).toContain('client_id=client-id'); + expect(body).toContain('client_secret=client-secret'); + }); + + it('ignores refreshUrl for a machine grant, which always asks the token endpoint', async () => { + mockedAxios.post.mockResolvedValue({ data: { access_token: 'cc-at', expires_in: 3600 } }); + + await service.refreshToken({ + grant: 'client_credentials', + tokenUrl: 'https://auth.example.com/token', + refreshUrl: 'https://auth.example.com/refresh', + clientId: 'client-id', + clientSecret: 'client-secret', + }); + + expect(mockedAxios.post.mock.calls[0][0]).toBe('https://auth.example.com/token'); + }); + it('should return null when tokenUrl is missing', async () => { const result = await service.refreshToken({ refreshToken: 'rt-abc', diff --git a/packages/backend/src/connectors/engines/oauth2-token.service.ts b/packages/backend/src/connectors/engines/oauth2-token.service.ts index 4a17f4701..b8ccbee5b 100644 --- a/packages/backend/src/connectors/engines/oauth2-token.service.ts +++ b/packages/backend/src/connectors/engines/oauth2-token.service.ts @@ -552,9 +552,16 @@ export class OAuth2TokenService { } } - await assertSafeOutboundUrl(tokenUrl); + // A few providers renew at their own endpoint: Worksection issues + // tokens at /oauth2/token but only refreshes at /oauth2/refresh, and + // answers a refresh sent to /oauth2/token without an access_token. + const postUrl = + !isMachineGrant(grant) && typeof authConfig.refreshUrl === 'string' && authConfig.refreshUrl + ? authConfig.refreshUrl + : tokenUrl; + await assertSafeOutboundUrl(postUrl); const response = await axios.post( - tokenUrl, + postUrl, new URLSearchParams(body).toString(), { headers,