@floomhq/signaldash 0.39.0 → 0.39.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +60 -5
- package/bin/sd.mjs +230 -11
- package/package.json +1 -1
- package/skills/signaldash/SKILL.md +63 -9
package/README.md
CHANGED
|
@@ -91,6 +91,24 @@ The MCP server uses the user token created by `login`. It cannot access a
|
|
|
91
91
|
LinkedIn, WhatsApp, or email account until that channel has been connected for
|
|
92
92
|
the same logged-in user.
|
|
93
93
|
|
|
94
|
+
## Without an MCP client
|
|
95
|
+
|
|
96
|
+
If the agent session cannot load the MCP tools (server connected, tools not
|
|
97
|
+
in the roster) the CLI reaches the exact same tool catalog through the exact
|
|
98
|
+
same backend routes and guards, no MCP transport required:
|
|
99
|
+
|
|
100
|
+
```bash
|
|
101
|
+
npx -y @floomhq/signaldash tools # full catalog: name, description, inputSchema
|
|
102
|
+
npx -y @floomhq/signaldash call li_list_chats '{"limit":5}' # dispatch one tool directly
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
`call` prints one JSON line to stdout and exits `0` on an HTTP 2xx, `1`
|
|
106
|
+
otherwise (including local argument validation failures, which never reach
|
|
107
|
+
the backend). It is not a fallback that skips anything: it is the same
|
|
108
|
+
dispatcher the MCP `tools/call` handler uses, so the write-control ledger,
|
|
109
|
+
per-account budgets, read-before-send, pacing, provider-warning lock, and
|
|
110
|
+
audit trail all run identically.
|
|
111
|
+
|
|
94
112
|
## Operating skill distribution
|
|
95
113
|
|
|
96
114
|
The canonical [`signaldash` skill](skills/signaldash/SKILL.md) teaches an agent
|
|
@@ -102,7 +120,7 @@ setup installs both from the same pinned npm package
|
|
|
102
120
|
the human chose to execute:
|
|
103
121
|
|
|
104
122
|
```bash
|
|
105
|
-
npx -y @floomhq/signaldash@0.39.
|
|
123
|
+
npx -y @floomhq/signaldash@0.39.1 <invite-code>
|
|
106
124
|
```
|
|
107
125
|
|
|
108
126
|
Run that command in a terminal, not in an agent chat. Do not ask an agent to
|
|
@@ -173,12 +191,12 @@ SignalDash exposes:
|
|
|
173
191
|
- `email_send(to, subject, body)`
|
|
174
192
|
- `li_my_posts(limit, member_id)`
|
|
175
193
|
- `li_post_reactions(post_id, limit, cursor?)`
|
|
176
|
-
- `li_post_comments(post_id, comment_id?, limit, cursor?)`
|
|
194
|
+
- `li_post_comments(post_id, comment_id?, resolve_reply_state?, limit, cursor?)`
|
|
177
195
|
- `li_reply_to_comment(post_id?, parent_comment_id?, trigger_comment_id?, text?, expected_watermark?, secretary_receipt_id?)`
|
|
178
196
|
- `li_like_comment(post_id, parent_comment_id, comment_id, expected_watermark?)`
|
|
179
197
|
- `li_delete_message(chat_id, message_id, confirm)`
|
|
180
198
|
- `li_delete_comment(post_id, comment_id, confirm)`
|
|
181
|
-
- `li_draft_post(text, publish, scheduled_at?, mentions?, attachments?, first_comment?)`
|
|
199
|
+
- `li_draft_post(text, publish, scheduled_at?, content_pipeline_id?, content_pipeline_override?, mentions?, attachments?, first_comment?)`
|
|
182
200
|
- `li_set_scheduled_post_first_comment(id, first_comment, confirm)`
|
|
183
201
|
- `li_scheduled_posts()`
|
|
184
202
|
- `li_cancel_scheduled_post(id, confirm)`
|
|
@@ -189,6 +207,16 @@ SignalDash exposes:
|
|
|
189
207
|
Every operation runs through the hosted SignalDash backend. Agents never
|
|
190
208
|
receive the Unipile access key.
|
|
191
209
|
|
|
210
|
+
When a user's content pipeline is enabled, a future post normally requires its
|
|
211
|
+
exact approved `content_pipeline_id`. A deliberate exception uses
|
|
212
|
+
`content_pipeline_override:{reason}` on the preview call, then repeats the
|
|
213
|
+
identical post, time, mentions, image bytes, first comment, and reason with
|
|
214
|
+
`confirm:true` plus the returned single-use `approval_hash`. The override never
|
|
215
|
+
disables the pipeline. Its reason, preview identity, and approval time are
|
|
216
|
+
stored atomically with the scheduled post and returned by
|
|
217
|
+
`li_scheduled_posts`. Override-approved payloads are immutable; their first
|
|
218
|
+
comment cannot be changed after scheduling.
|
|
219
|
+
|
|
192
220
|
`li_start_chat` and `wa_start_chat` are separate tools because LinkedIn and
|
|
193
221
|
WhatsApp expose different stable member-ID formats and spend different budget
|
|
194
222
|
lanes. Both use the same safety contract. A first call previews 1 to 10 exact
|
|
@@ -227,6 +255,30 @@ readback. `sd_secretary_push_set` is the database kill switch; enabling starts
|
|
|
227
255
|
from that instant so old rows do not create a backlog. The self-chat is never a
|
|
228
256
|
fallback.
|
|
229
257
|
|
|
258
|
+
A top-level `li_post_comments` page reports each comment's `reply_counter` but
|
|
259
|
+
no reply objects, which left "did I already answer this?" unanswerable and kept
|
|
260
|
+
the comment loop switched off. `resolve_reply_state:true` reads each comment's
|
|
261
|
+
reply thread and attaches a `reply_state`. Presence and absence are proved to
|
|
262
|
+
different standards on purpose. Seeing an own reply proves `replied_by_me:true`
|
|
263
|
+
whatever the page's completeness, because presence on an incomplete page is
|
|
264
|
+
still presence. `replied_by_me:false` is emitted only when the reply set is
|
|
265
|
+
provably whole, every reply carries a resolvable `author_details.id`, ids are
|
|
266
|
+
unique, every reply belongs to this thread, and no reply is the account
|
|
267
|
+
owner's. Two proofs are accepted: the provider reporting the reply page
|
|
268
|
+
complete, or a zero `reply_counter` together with a reply read that came back
|
|
269
|
+
empty, which is the same pair the reply preflight already requires.
|
|
270
|
+
`replies_read === reply_counter` is deliberately NOT a proof; both numbers are
|
|
271
|
+
returned so a caller can see the corroboration, but SignalDash will not convert
|
|
272
|
+
it into a boolean, because a wrong `false` makes an agent talk over the account
|
|
273
|
+
owner in public. Everything else is `replied_by_me:null` with an explicit
|
|
274
|
+
reason. The flag is off by default and unresolved comments still carry
|
|
275
|
+
`reply_state.state = "unknown"`, so a miss is never mistaken for a proven
|
|
276
|
+
absence. Resolution is bounded per request by
|
|
277
|
+
`SIGNALDASH_REPLY_STATE_MAX_READS` (default 25); that bound is not a daily read
|
|
278
|
+
cap and does not claim to be one. A provider warning, 403 or 429 stops the
|
|
279
|
+
sweep at once and the whole call returns non-2xx carrying the partial evidence,
|
|
280
|
+
so a safety condition is never downgraded into a 200.
|
|
281
|
+
|
|
230
282
|
`li_reply_to_comment` acts only on an inbound comment on the authenticated
|
|
231
283
|
sender's own post. A preceding `li_post_comments` read records an exact
|
|
232
284
|
per-comment watermark. Immediately before the write, SignalDash re-proves the
|
|
@@ -519,8 +571,11 @@ entries strictly one at a time, pauses `SIGNALDASH_WA_DELETE_PACE_MS` between
|
|
|
519
571
|
them, and stops at `SIGNALDASH_WA_DELETE_BATCH_DEADLINE_MS`, returning the
|
|
520
572
|
untouched remainder as `skipped` with `code: batch_deadline` so the caller can
|
|
521
573
|
resume exactly those. WhatsApp applies its own time and role limits to deleting
|
|
522
|
-
for everyone and can answer successfully without removing anything
|
|
523
|
-
the
|
|
574
|
+
for everyone and can answer successfully without removing anything. SignalDash
|
|
575
|
+
therefore re-reads the canonical provider message and returns success only when
|
|
576
|
+
`deleted: 1` or a genuine `404` proves it gone. A present row or failed
|
|
577
|
+
readback returns `502 deleted_unconfirmed`, records an unknown outcome, and is
|
|
578
|
+
not retryable.
|
|
524
579
|
|
|
525
580
|
## Deploying server changes
|
|
526
581
|
|
package/bin/sd.mjs
CHANGED
|
@@ -59,7 +59,37 @@ function updateCfg(update) {
|
|
|
59
59
|
return updateConfigFile(configPaths().file, update);
|
|
60
60
|
}
|
|
61
61
|
|
|
62
|
-
|
|
62
|
+
function fallbackApiError(response) {
|
|
63
|
+
const header = name => response.headers?.get?.(name) || null;
|
|
64
|
+
const declaredCode = header("x-signaldash-error-code");
|
|
65
|
+
const upstreamStatusHeader = header("x-signaldash-upstream-status");
|
|
66
|
+
const declaredUpstreamStatus = upstreamStatusHeader === null
|
|
67
|
+
? null
|
|
68
|
+
: Number(upstreamStatusHeader);
|
|
69
|
+
const requestId = header("x-signaldash-request-id");
|
|
70
|
+
const upstreamStatus = Number.isInteger(declaredUpstreamStatus)
|
|
71
|
+
? declaredUpstreamStatus
|
|
72
|
+
: null;
|
|
73
|
+
const messages = {
|
|
74
|
+
upstream_authentication_failed:
|
|
75
|
+
"Messaging provider authentication is unavailable.",
|
|
76
|
+
outcome_unknown:
|
|
77
|
+
"The provider outcome is unknown. Read the exact thread before any retry.",
|
|
78
|
+
};
|
|
79
|
+
const code = declaredCode || "backend_error_response_unreadable";
|
|
80
|
+
return {
|
|
81
|
+
error: messages[code]
|
|
82
|
+
|| "SignalDash returned an unreadable error response. The action outcome is unknown; read the exact thread before any retry.",
|
|
83
|
+
code,
|
|
84
|
+
http_status: response.status,
|
|
85
|
+
...(upstreamStatus !== null ? { upstream_status: upstreamStatus } : {}),
|
|
86
|
+
...(requestId ? { request_id: requestId } : {}),
|
|
87
|
+
retryable: false,
|
|
88
|
+
...(declaredCode ? {} : { outcome_unknown: true }),
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export async function api(
|
|
63
93
|
path,
|
|
64
94
|
body,
|
|
65
95
|
{ auth = true, method = "POST", backend, token } = {},
|
|
@@ -75,7 +105,26 @@ async function api(
|
|
|
75
105
|
},
|
|
76
106
|
...(method === "GET" ? {} : { body: JSON.stringify(body || {}) }),
|
|
77
107
|
});
|
|
78
|
-
|
|
108
|
+
let json = await r.json().catch(() => null);
|
|
109
|
+
if (
|
|
110
|
+
r.status >= 300 &&
|
|
111
|
+
(
|
|
112
|
+
!json ||
|
|
113
|
+
typeof json !== "object" ||
|
|
114
|
+
Array.isArray(json) ||
|
|
115
|
+
Object.keys(json).length === 0
|
|
116
|
+
)
|
|
117
|
+
) {
|
|
118
|
+
// An intermediary can discard or replace a backend error body. Returning
|
|
119
|
+
// `{}` here erased the only facts an MCP caller can use to distinguish a
|
|
120
|
+
// definite provider rejection from a send whose delivery is unknown.
|
|
121
|
+
// The backend repeats its safe classification in headers; when even those
|
|
122
|
+
// are absent, fail closed as outcome-unknown rather than imply a clean
|
|
123
|
+
// failure that an agent may retry into a duplicate.
|
|
124
|
+
json = fallbackApiError(r);
|
|
125
|
+
} else if (json === null) {
|
|
126
|
+
json = {};
|
|
127
|
+
}
|
|
79
128
|
if (
|
|
80
129
|
json &&
|
|
81
130
|
typeof json === "object" &&
|
|
@@ -1350,7 +1399,7 @@ const TOOLS = [
|
|
|
1350
1399
|
{
|
|
1351
1400
|
name: "wa_delete_message",
|
|
1352
1401
|
path: "/wa/delete_message",
|
|
1353
|
-
description: "Delete one WhatsApp message this account SENT, in a chat this account owns. Read the chat first: the exact `message_id` comes from `wa_read_messages`. Only your own messages can be deleted; someone else's is refused with `403 message_not_own`. This is irreversible and is never retried: a delete already recorded for this exact chat and message is refused with `409 duplicate_delete` rather than replayed.
|
|
1402
|
+
description: "Delete one WhatsApp message this account SENT, in a chat this account owns. Read the chat first: the exact `message_id` comes from `wa_read_messages`. Only your own messages can be deleted; someone else's is refused with `403 message_not_own`. This is irreversible and is never retried: a delete already recorded for this exact chat and message is refused with `409 duplicate_delete` rather than replayed. SignalDash re-reads after the provider accepts the delete and returns success only when `deleted:1` or a genuine 404 proves the message gone; otherwise it returns `502 deleted_unconfirmed` and refuses a retry. Deletes spend their own daily budget and never consume your send budget.",
|
|
1354
1403
|
inputSchema: {
|
|
1355
1404
|
type: "object",
|
|
1356
1405
|
properties: {
|
|
@@ -1480,12 +1529,13 @@ const TOOLS = [
|
|
|
1480
1529
|
{
|
|
1481
1530
|
name: "li_post_comments",
|
|
1482
1531
|
path: "/li/post_comments",
|
|
1483
|
-
description: "Read one bounded page of comments and authors on a post. Pass comment_id to read replies to that exact parent comment. Pass the exact social_id returned by li_my_posts; a numeric id is resolved against your own recent posts when possible. Read completeness.state, completeness.total, and completeness.next_cursor before treating the list as complete, then pass cursor to continue. The true total comes from the provider response when available; SignalDash never guesses it from li_my_posts.",
|
|
1532
|
+
description: "Read one bounded page of comments and authors on a post. Pass comment_id to read replies to that exact parent comment. Pass resolve_reply_state:true to also learn, per comment, whether you already replied: each comment then carries reply_state with replied_by_me true, false, or null. Only treat a comment as unanswered when reply_state.state is no_replies or answered_by_others; null means unknown and must never be read as nobody-replied. Pass the exact social_id returned by li_my_posts; a numeric id is resolved against your own recent posts when possible. Read completeness.state, completeness.total, and completeness.next_cursor before treating the list as complete, then pass cursor to continue. The true total comes from the provider response when available; SignalDash never guesses it from li_my_posts.",
|
|
1484
1533
|
inputSchema: {
|
|
1485
1534
|
type: "object",
|
|
1486
1535
|
properties: {
|
|
1487
1536
|
post_id: { type: "string", minLength: 1, maxLength: 500 },
|
|
1488
1537
|
comment_id: { type: "string", minLength: 1, maxLength: 500, description: "Optional exact parent comment id. When present, returns replies to that comment." },
|
|
1538
|
+
resolve_reply_state: { type: "boolean", description: "Read the connected account identity once and each comment's reply thread to prove whether you already replied. Reply-thread reads are bounded per request. Cannot be combined with comment_id. When omitted every comment still carries reply_state with state unknown, so a miss is never mistaken for a proven absence." },
|
|
1489
1539
|
limit: { type: "integer", minimum: 1, maximum: 100 },
|
|
1490
1540
|
cursor: { type: "string", minLength: 1, maxLength: 4000 },
|
|
1491
1541
|
},
|
|
@@ -1529,7 +1579,7 @@ const TOOLS = [
|
|
|
1529
1579
|
{
|
|
1530
1580
|
name: "li_delete_message",
|
|
1531
1581
|
path: "/li/delete_message",
|
|
1532
|
-
description: "Remediate one exact LinkedIn message sent by this authenticated account, only within the provider's 60-minute window. Requires exact chat and message identity plus confirm:true. SignalDash proves chat ownership, exact-chat membership, own authorship, timestamp eligibility, a separate remediation budget, and post-delete
|
|
1582
|
+
description: "Remediate one exact LinkedIn message sent by this authenticated account, only within the provider's 60-minute window. Requires exact chat and message identity plus confirm:true. SignalDash proves chat ownership, exact-chat membership, own authorship, timestamp eligibility, a separate remediation budget, and the provider's post-delete state. Success requires `deleted:1` or a genuine 404; otherwise the tool returns `502 deleted_unconfirmed`, locks the sender, and never retries automatically. Deletion cannot undo prior delivery, reading, or notifications and does not weaken any send gate.",
|
|
1533
1583
|
inputSchema: {
|
|
1534
1584
|
type: "object",
|
|
1535
1585
|
properties: {
|
|
@@ -1559,7 +1609,7 @@ const TOOLS = [
|
|
|
1559
1609
|
{
|
|
1560
1610
|
name: "li_draft_post",
|
|
1561
1611
|
path: "/li/create_post",
|
|
1562
|
-
description: "Draft, publish, or schedule a LinkedIn post. Scheduling requires an offset-qualified scheduled_at plus publish:true after exact human approval. Optional mentions, base64 image attachments, and an account-owner-authored first_comment are preserved for the scheduled publish.",
|
|
1612
|
+
description: "Draft, publish, or schedule a LinkedIn post. Scheduling requires an offset-qualified scheduled_at plus publish:true after exact human approval. When the content pipeline is enabled, use an approved content_pipeline_id or preview one exact reasoned content_pipeline_override and repeat it with confirm:true plus its single-use approval_hash. Optional mentions, base64 image attachments, and an account-owner-authored first_comment are preserved for the scheduled publish.",
|
|
1563
1613
|
inputSchema: {
|
|
1564
1614
|
type: "object",
|
|
1565
1615
|
properties: {
|
|
@@ -1567,6 +1617,16 @@ const TOOLS = [
|
|
|
1567
1617
|
publish: { type: "boolean" },
|
|
1568
1618
|
scheduled_at: { type: "string", format: "date-time" },
|
|
1569
1619
|
content_pipeline_id: { type: "string", minLength: 1, maxLength: 200 },
|
|
1620
|
+
content_pipeline_override: {
|
|
1621
|
+
type: "object",
|
|
1622
|
+
properties: {
|
|
1623
|
+
reason: { type: "string", minLength: 8, maxLength: 500 },
|
|
1624
|
+
confirm: { type: "boolean", const: true },
|
|
1625
|
+
approval_hash: { type: "string", minLength: 64, maxLength: 64, pattern: "^[0-9a-f]{64}$" },
|
|
1626
|
+
},
|
|
1627
|
+
required: ["reason"],
|
|
1628
|
+
additionalProperties: false,
|
|
1629
|
+
},
|
|
1570
1630
|
first_comment: { type: "string", minLength: 1, maxLength: 1250 },
|
|
1571
1631
|
mentions: {
|
|
1572
1632
|
type: "array", maxItems: 20,
|
|
@@ -1713,6 +1773,64 @@ function mcpTool(name) {
|
|
|
1713
1773
|
inputSchema: tool.inputSchema,
|
|
1714
1774
|
};
|
|
1715
1775
|
}
|
|
1776
|
+
|
|
1777
|
+
// The single source of truth for the tool catalog an agent can see, in full
|
|
1778
|
+
// (name, description, inputSchema) -- not just names. MCP `tools/list` and
|
|
1779
|
+
// the CLI `signaldash tools` both call this, so the two surfaces can never
|
|
1780
|
+
// drift: one entry dropped for a missing inputSchema (see mcpTool above)
|
|
1781
|
+
// disappears from both at once instead of only one of them.
|
|
1782
|
+
export function listTools() {
|
|
1783
|
+
return TOOLS.map(t => mcpTool(t.name)).filter(Boolean);
|
|
1784
|
+
}
|
|
1785
|
+
|
|
1786
|
+
// The one dispatcher behind every tool call, whichever door it came through.
|
|
1787
|
+
// MCP `tools/call` (stdio) and the CLI `call` command both resolve `name` in
|
|
1788
|
+
// this exact TOOLS lookup and hit this exact backend route with these exact
|
|
1789
|
+
// arguments -- nothing about a "second path" is a second implementation.
|
|
1790
|
+
// Every guard an agent depends on (write-control ledger, per-account budget,
|
|
1791
|
+
// read-before-send watermark, pacing/jitter, provider-warning lock, audit
|
|
1792
|
+
// trail) lives in server.cjs keyed off the route and the bearer token's
|
|
1793
|
+
// account, not off which client asked, so this function has no guard logic
|
|
1794
|
+
// of its own to keep in sync -- there is exactly one enforcement point, and
|
|
1795
|
+
// both callers share it. Returns `{ unknown: true }` for a name not in
|
|
1796
|
+
// TOOLS; callers translate that into their own transport's error shape
|
|
1797
|
+
// (MCP: JSON-RPC -32601, unchanged from before this refactor; CLI: a local
|
|
1798
|
+
// JSON error understood by scripts) so the shared function stays agnostic
|
|
1799
|
+
// to which door is asking.
|
|
1800
|
+
export async function callTool(name, args, dependencies = {}) {
|
|
1801
|
+
const request = dependencies.request || api;
|
|
1802
|
+
const t = TOOLS.find(x => x.name === name);
|
|
1803
|
+
if (!t) return { unknown: true };
|
|
1804
|
+
try {
|
|
1805
|
+
return await request(t.path || `/${t.ch}/${t.action}`, args || {});
|
|
1806
|
+
} catch (error) {
|
|
1807
|
+
// A rejected fetch (DNS failure, connection refused, timeout, TLS
|
|
1808
|
+
// error -- anything below the HTTP layer) has no status code and no
|
|
1809
|
+
// response body, and previously escaped uncaught: from the CLI that
|
|
1810
|
+
// broke the documented one-JSON-line/exit-1 contract with a raw stack
|
|
1811
|
+
// trace on stderr, and from MCP's stdio loop it would have crashed the
|
|
1812
|
+
// whole server on one bad network blip, taking every other tool down
|
|
1813
|
+
// with it for the rest of the session. 599 is the conventional
|
|
1814
|
+
// "no real HTTP response" pseudo-status other HTTP clients use for
|
|
1815
|
+
// exactly this case; it satisfies both callers' existing `status >= 300`
|
|
1816
|
+
// / `200 <= status < 300` checks without a third branch at either call
|
|
1817
|
+
// site, so a transport failure gets the same "this was not a success"
|
|
1818
|
+
// treatment as any other non-2xx response. Fails closed exactly like
|
|
1819
|
+
// fallbackApiError above: the outcome is unknown, not a clean failure
|
|
1820
|
+
// safe to retry into a possible duplicate.
|
|
1821
|
+
return {
|
|
1822
|
+
status: 599,
|
|
1823
|
+
json: {
|
|
1824
|
+
error: "SignalDash could not reach the backend. The action outcome is unknown; read the exact thread before any retry.",
|
|
1825
|
+
code: "request_failed",
|
|
1826
|
+
detail: error && error.message ? error.message : String(error),
|
|
1827
|
+
retryable: false,
|
|
1828
|
+
outcome_unknown: true,
|
|
1829
|
+
},
|
|
1830
|
+
};
|
|
1831
|
+
}
|
|
1832
|
+
}
|
|
1833
|
+
|
|
1716
1834
|
export async function runMcp(dependencies = {}) {
|
|
1717
1835
|
const input = dependencies.input || process.stdin;
|
|
1718
1836
|
const output = dependencies.output || process.stdout;
|
|
@@ -1723,11 +1841,15 @@ export async function runMcp(dependencies = {}) {
|
|
|
1723
1841
|
let msg; try { msg = JSON.parse(line); } catch { continue; }
|
|
1724
1842
|
const { id, method, params } = msg;
|
|
1725
1843
|
if (method === "initialize") reply(id, { protocolVersion: "2024-11-05", capabilities: { tools: {} }, serverInfo: { name: "signaldash", version: PACKAGE_VERSION } });
|
|
1726
|
-
else if (method === "tools/list") reply(id, { tools:
|
|
1844
|
+
else if (method === "tools/list") reply(id, { tools: listTools() });
|
|
1727
1845
|
else if (method === "tools/call") {
|
|
1728
|
-
|
|
1729
|
-
|
|
1730
|
-
|
|
1846
|
+
// Unknown-tool handling is preserved byte-for-byte from before this
|
|
1847
|
+
// refactor (JSON-RPC -32601, no content/isError envelope): callTool()
|
|
1848
|
+
// reports `unknown: true` instead of throwing so this branch can keep
|
|
1849
|
+
// exactly its old reply shape while the CLI's `call` command (below)
|
|
1850
|
+
// gets its own, different, JSON-on-stdout shape for the same case.
|
|
1851
|
+
const r = await callTool(params.name, params.arguments || {}, { request });
|
|
1852
|
+
if (r.unknown) { reply(id, null, { code: -32601, message: "unknown tool" }); continue; }
|
|
1731
1853
|
reply(id, { content: [{ type: "text", text: JSON.stringify(r.json) }], isError: r.status >= 300 });
|
|
1732
1854
|
} else if (id !== undefined) reply(id, {});
|
|
1733
1855
|
}
|
|
@@ -1829,8 +1951,11 @@ export async function cmdStatus(dependencies = {}) {
|
|
|
1829
1951
|
for (const provider of ["linkedin", "whatsapp", "email"]) {
|
|
1830
1952
|
const r = await request(`/connect/${provider}/status`, undefined, { method: "GET" });
|
|
1831
1953
|
const ok = r.status === 200 && r.json.connected;
|
|
1954
|
+
const unavailable = r.json?.code === "upstream_authentication_failed"
|
|
1955
|
+
? `authentication failed (provider HTTP ${r.json.upstream_status || "unknown"})`
|
|
1956
|
+
: "not connected";
|
|
1832
1957
|
log(" " + (ok ? chalk.green("+") : chalk.dim("-")) + " " + provider.padEnd(9) +
|
|
1833
|
-
(ok ? chalk.dim(r.json.name || "connected") : chalk.dim(
|
|
1958
|
+
(ok ? chalk.dim(r.json.name || "connected") : chalk.dim(unavailable)));
|
|
1834
1959
|
}
|
|
1835
1960
|
log("");
|
|
1836
1961
|
}
|
|
@@ -1872,6 +1997,90 @@ export async function cmdConnections(outPath, dependencies = {}) {
|
|
|
1872
1997
|
}
|
|
1873
1998
|
|
|
1874
1999
|
|
|
2000
|
+
// Serializes exactly one JSON value with a trailing newline, and nothing
|
|
2001
|
+
// else -- no chalk, no ora, no progress text. Both `call` and `tools` write
|
|
2002
|
+
// through this so a script piping the CLI's stdout never has to skip
|
|
2003
|
+
// decorative lines to find the JSON.
|
|
2004
|
+
function jsonLine(value) {
|
|
2005
|
+
return `${JSON.stringify(value)}\n`;
|
|
2006
|
+
}
|
|
2007
|
+
|
|
2008
|
+
async function readAll(stream) {
|
|
2009
|
+
const chunks = [];
|
|
2010
|
+
for await (const chunk of stream) {
|
|
2011
|
+
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk)));
|
|
2012
|
+
}
|
|
2013
|
+
return Buffer.concat(chunks).toString("utf8");
|
|
2014
|
+
}
|
|
2015
|
+
|
|
2016
|
+
// The second sanctioned path from issue #100: when an agent session's tool
|
|
2017
|
+
// roster does not include the MCP `li_*`/`wa_*`/`sd_*` tools (server
|
|
2018
|
+
// connected, but the client never loaded them), this CLI verb reaches the
|
|
2019
|
+
// exact same catalog and the exact same backend routes through callTool()
|
|
2020
|
+
// above, so it carries every guard the MCP path carries -- there is nothing
|
|
2021
|
+
// else to carry, because callTool() IS the MCP path's dispatcher too.
|
|
2022
|
+
//
|
|
2023
|
+
// Deliberately machine-facing, not human-facing: `signaldash status` and
|
|
2024
|
+
// friends print colored, human-readable lines; `call` and `tools` print
|
|
2025
|
+
// exactly one JSON value to stdout and communicate everything else (success,
|
|
2026
|
+
// refusal, malformed input) through the exit code, so an agent can script
|
|
2027
|
+
// against them without scraping prose.
|
|
2028
|
+
export async function cmdCall(argv, dependencies = {}) {
|
|
2029
|
+
const request = dependencies.request || api;
|
|
2030
|
+
const input = dependencies.input || process.stdin;
|
|
2031
|
+
const write = dependencies.write || (text => process.stdout.write(text));
|
|
2032
|
+
const [name, ...rest] = argv;
|
|
2033
|
+
if (!name || rest.length > 1) {
|
|
2034
|
+
write(jsonLine({ error: "usage: signaldash call <tool_name> ['<json-args>']", code: "usage" }));
|
|
2035
|
+
process.exitCode = 1;
|
|
2036
|
+
return;
|
|
2037
|
+
}
|
|
2038
|
+
let raw;
|
|
2039
|
+
if (rest.length === 1) {
|
|
2040
|
+
raw = rest[0];
|
|
2041
|
+
} else {
|
|
2042
|
+
// No inline JSON argument: read stdin to EOF when it is piped (a real
|
|
2043
|
+
// agent invocation), but never block on a live TTY waiting for input
|
|
2044
|
+
// nobody is going to type -- an empty/absent body means `{}`.
|
|
2045
|
+
raw = input.isTTY ? "" : await readAll(input);
|
|
2046
|
+
}
|
|
2047
|
+
const trimmed = (raw || "").trim();
|
|
2048
|
+
let args = {};
|
|
2049
|
+
if (trimmed) {
|
|
2050
|
+
let parsed;
|
|
2051
|
+
try {
|
|
2052
|
+
parsed = JSON.parse(trimmed);
|
|
2053
|
+
} catch {
|
|
2054
|
+
write(jsonLine({ error: "arguments must be valid JSON", code: "invalid_json" }));
|
|
2055
|
+
process.exitCode = 1;
|
|
2056
|
+
return;
|
|
2057
|
+
}
|
|
2058
|
+
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
2059
|
+
write(jsonLine({ error: "arguments must be a JSON object", code: "invalid_json" }));
|
|
2060
|
+
process.exitCode = 1;
|
|
2061
|
+
return;
|
|
2062
|
+
}
|
|
2063
|
+
args = parsed;
|
|
2064
|
+
}
|
|
2065
|
+
const r = await callTool(name, args, { request });
|
|
2066
|
+
if (r.unknown) {
|
|
2067
|
+
write(jsonLine({ error: "unknown tool", code: "unknown_tool", tool: name }));
|
|
2068
|
+
process.exitCode = 1;
|
|
2069
|
+
return;
|
|
2070
|
+
}
|
|
2071
|
+
write(jsonLine(r.json));
|
|
2072
|
+
process.exitCode = r.status >= 200 && r.status < 300 ? 0 : 1;
|
|
2073
|
+
}
|
|
2074
|
+
|
|
2075
|
+
// The exact same catalog `tools/list` gives an MCP client, in full (name,
|
|
2076
|
+
// description, inputSchema) -- so an agent using `call` can discover the
|
|
2077
|
+
// right tool name and argument shape without needing the MCP transport
|
|
2078
|
+
// loaded at all.
|
|
2079
|
+
export async function cmdTools(dependencies = {}) {
|
|
2080
|
+
const write = dependencies.write || (text => process.stdout.write(text));
|
|
2081
|
+
write(jsonLine(listTools()));
|
|
2082
|
+
}
|
|
2083
|
+
|
|
1875
2084
|
function printHelp(log = console.log) {
|
|
1876
2085
|
log(`SignalDash \u2014 secure LinkedIn, WhatsApp and email access for your AI agent.
|
|
1877
2086
|
|
|
@@ -1882,6 +2091,14 @@ function printHelp(log = console.log) {
|
|
|
1882
2091
|
signaldash connections [file.csv] export your LinkedIn connections
|
|
1883
2092
|
signaldash skill install the agent skill
|
|
1884
2093
|
signaldash mcp run the MCP server (used by your agent)
|
|
2094
|
+
signaldash tools list every tool as JSON (name, description, inputSchema)
|
|
2095
|
+
signaldash call <tool> ['<json-args>']
|
|
2096
|
+
call one tool directly -- same routes and
|
|
2097
|
+
guards as your agent's MCP tools. A second
|
|
2098
|
+
sanctioned path for when MCP tools are not
|
|
2099
|
+
loaded in this session; reads args from the
|
|
2100
|
+
given JSON or from stdin, prints one JSON
|
|
2101
|
+
line, exits 0 on HTTP 2xx else 1.
|
|
1885
2102
|
signaldash logout revoke this device
|
|
1886
2103
|
|
|
1887
2104
|
Your channel credentials stay on the SignalDash server. They are never stored on
|
|
@@ -1903,6 +2120,8 @@ export async function main(argv = process.argv.slice(2), dependencies = {}) {
|
|
|
1903
2120
|
else if (cmd === "connections" || (cmd === "export" && a === "connections")) await cmdConnections(cmd === "export" ? b : a, dependencies);
|
|
1904
2121
|
else if (cmd === "--version" || cmd === "-v") log(PACKAGE_VERSION);
|
|
1905
2122
|
else if (cmd === "skill") await cmdSkill(dependencies);
|
|
2123
|
+
else if (cmd === "tools") await cmdTools(dependencies);
|
|
2124
|
+
else if (cmd === "call") await cmdCall(argv.slice(1), dependencies);
|
|
1906
2125
|
else if (cmd && !["help","--help","-h"].includes(cmd) && !INVITE_CODE_REGEX.test(cmd)) { (dependencies.error || console.error)(`unknown command: ${cmd}`); printHelp(log); process.exitCode = 1; }
|
|
1907
2126
|
else printHelp(log);
|
|
1908
2127
|
}
|
package/package.json
CHANGED
|
@@ -299,7 +299,24 @@ li_list_chats({"limit": 5})
|
|
|
299
299
|
|
|
300
300
|
If the tools are missing but `status` is connected, the account is ready and
|
|
301
301
|
the MCP client is not loaded. Fix the MCP registration or restart the client;
|
|
302
|
-
do not reconnect the account.
|
|
302
|
+
do not reconnect the account. **This is not a reason to use Unipile, the
|
|
303
|
+
provider, or any other path around SignalDash.** If restarting the client is
|
|
304
|
+
not possible in this session, use the CLI directly instead: it is the same
|
|
305
|
+
account, the same backend, and the same guards, only a different door.
|
|
306
|
+
|
|
307
|
+
```bash
|
|
308
|
+
npx -y @floomhq/signaldash tools
|
|
309
|
+
npx -y @floomhq/signaldash call li_list_chats '{"limit": 5}'
|
|
310
|
+
```
|
|
311
|
+
|
|
312
|
+
`call` dispatches through the identical route and argument shape as the MCP
|
|
313
|
+
tool of the same name -- the write-control ledger, per-account budget,
|
|
314
|
+
read-before-send, pacing, provider-warning lock, and audit trail all run
|
|
315
|
+
exactly as they do for an MCP call, because both go through the same backend
|
|
316
|
+
route. `tools` lists the full catalog (name, description, `inputSchema`) as
|
|
317
|
+
JSON so the right tool name and argument shape are discoverable without MCP.
|
|
318
|
+
`call` prints exactly one JSON line to stdout and exits 0 on success, 1
|
|
319
|
+
otherwise; script against the exit code and that one line, not prose.
|
|
303
320
|
|
|
304
321
|
## Numbered workflow for every account task
|
|
305
322
|
|
|
@@ -419,19 +436,19 @@ Use the exact tool names and argument keys below. Limits are optional.
|
|
|
419
436
|
| `wa_transcribe_voice` | `chat_id`, `message_id`, `attachment_id` all required, max 500 characters each; `backend` optional, exactly `gemini` or `whisper` | Turn one WhatsApp voice note into text through SignalDash instead of fetching provider bytes yourself. Always read the returned `backend`: `gemini` is the accurate default, `whisper-small` is the weak local fallback and mangles German with English terms mixed in, and a fallback also carries `fallback_reason`. Non-audio attachments are refused with `415 not_audio`; an unknown backend with `400 unknown_backend`; a transcription that exceeds its time limit returns `504 transcription_timeout` with the stored audio path. |
|
|
420
437
|
| `wa_start_chat` | `member_ids` required array of 1-10 exact `@s.whatsapp.net` or `@lid` provider member IDs; `text` required exact first message; `confirm`, `approval_hash`, and `dry_run:true` apply only after preview | Start one direct or group chat under the same exact approval, live member resolution, protected-contact, duplicate-set, and readback contract as LinkedIn. If an `@lid` resolves to another canonical ID, repeat the preview with the returned `resolved_member_id`; SignalDash never substitutes an unapproved identity. A new chat spends one WhatsApp/email send action regardless of member count. An existing exact member-set chat is returned without sending. |
|
|
421
438
|
| `wa_send_message` | `chat_id` required; `text` optional only when a file is attached, max 5000 characters; `attachments` optional array of up to 4 exact `{filename, content_type, content_base64}` files, at most 16 MiB per file and 16 MiB per message, types `image/png`, `image/jpeg`, `image/webp`, `image/gif`, `application/pdf`, `text/csv`, `text/plain`, `application/json`, `application/zip`, xlsx; `expected_watermark` optional exact 64-character watermark; `mark_read` optional boolean, default false | Send one approved reply, one approved file, or both, in an existing WhatsApp conversation after an immediate re-read. Own outbound additions do not invalidate the read; inbound additions or mutations return `new_messages`, `changed_kind`, and `current_watermark`. Pass `expected_watermark` to bind the send to the exact reviewed state and `mark_read:true` only when the approved workflow also calls for clearing unread after the confirmed send. A file spends the same daily send budget and is recorded the same way as a text message; there is no separate attachment budget. A call carrying neither text nor an attachment is refused with `400 text_or_attachment_required`. Attachments are checked before anything is reserved, so a refusal costs no send: `400 unsupported_attachment_type`, `400 attachment_too_large`, `400 attachments_too_large`, `400 too_many_attachments`, `400 malformed_attachment_base64`, `400 invalid_attachment_filename`, and `413 request_too_large` when the whole body is too big to read. Nothing is ever truncated or dropped silently. The same caption with the same file is refused as `409 duplicate_send`; the same caption with a different file is a different message and goes through. If a send times out or the provider never confirms it, the message may still have been delivered: an identical retry is refused with `409 send_outcome_unknown`. Read the chat AGAIN, and ONLY if the message is genuinely absent, resend the identical payload with `confirm_resend:true`. The re-read is enforced, not advisory: a `confirm_resend` whose most recent read of that chat predates the failed attempt is refused with `428 reread_after_failed_send_required`, because a read taken before the attempt cannot show whether the message arrived. A re-read failure remains `502 thread_preflight_unavailable`, and a legacy proof with no watermark remains `428 read_before_send_required`; neither costs send budget. LinkedIn messages carry text only. |
|
|
422
|
-
| `wa_delete_message` | `chat_id`, `message_id` both required, max 500 characters each | Retract one message THIS account sent, in a chat this account owns. The exact `message_id` comes from `wa_read_messages`. Irreversible and never retried: someone else's message is refused with `403 message_not_own`, a message outside this chat with `403 message_forbidden`, and a delete already recorded for this exact chat and message with `409 duplicate_delete`. Deletes spend their own daily budget, so `429 rate_limit_exceeded` here never means you are out of sends.
|
|
439
|
+
| `wa_delete_message` | `chat_id`, `message_id` both required, max 500 characters each | Retract one message THIS account sent, in a chat this account owns. The exact `message_id` comes from `wa_read_messages`. Irreversible and never retried: someone else's message is refused with `403 message_not_own`, a message outside this chat with `403 message_forbidden`, and a delete already recorded for this exact chat and message with `409 duplicate_delete`. Deletes spend their own daily budget, so `429 rate_limit_exceeded` here never means you are out of sends. SignalDash re-reads after the delete and returns success only when `deleted:1` or a genuine 404 proves the message gone. A present row or failed readback returns `502 deleted_unconfirmed`, records an unknown outcome, and must not be retried. |
|
|
423
440
|
| `wa_delete_messages` | `messages` required array of 1-200 exact `{chat_id, message_id}` objects | Retract several messages this account sent. Same ownership, budget and audit path as `wa_delete_message`, executed strictly one at a time with a pause between them, never in parallel. Always read the per-entry `ok`, `code` and `error`: a partial result is normal. Entries the batch never reached before its time limit come back with `skipped:true` and `code:batch_deadline`, and were not attempted; resend exactly those to resume. |
|
|
424
441
|
| `email_list` | `limit` integer 1-100, default 20; `cursor` optional, max 4096 | List the newest message in each recent email thread and obtain `thread_id`. The response carries a `cursor`; pass it back to read the next page, and omit it for the first. |
|
|
425
442
|
| `email_read` | `thread_id` required; `limit` 1-100, default 30 | Read an email thread and authorize its exact participant addresses for a later send. Everyone on `cc` counts as a participant, so a read authorizes them too. The newest messages are returned, not the oldest, so the people being replied to are always in the window. |
|
|
426
443
|
| `email_send` | `to` required as an array of exactly one valid address; `subject` required, max 998, single line; `body` required, max 5000; `thread_id` optional, max 500 | Send one approved email to a participant in a recently read existing thread. Pass the `thread_id` you read to reply inside that thread; omit it only when starting a new one. A blank or oversized `thread_id` is refused with `400 invalid_request` rather than quietly starting a new thread beside the original. The same subject and body to the same person in the same thread is refused as `409 duplicate_send`; the same words in a different thread are a different message and go through. A subject containing a line break is refused with `400 invalid_request`, because a subject is one header line; a body with line breaks is normal and sends. If a send leaves this host and the provider never answers, the retry is refused as `409 send_outcome_unknown`: read the thread again, and only if the email is genuinely absent resend with `confirm_resend: true`. |
|
|
427
444
|
| `li_my_posts` | `limit` default 10, max 50; `member_id` optional | Find the user's latest posts and exact `social_id` values. Omit `member_id` to use the connected user's own ID. Pass `social_id`, not a different numeric `id`, to the engagement tools. |
|
|
428
445
|
| `li_post_reactions` | `post_id` required; use the exact `social_id` from `li_my_posts`; `limit` default 50, max 100; `cursor` optional | Read one bounded page of reactors. Always inspect `completeness.state`, `total`, and `next_cursor`; continue with the cursor while state is `incomplete`. Numeric ids are resolved against the user's own recent posts when possible, including ugcPost-backed multi-image posts. The provider total is reported when available and is never guessed from `li_my_posts`. A reaction does not authorize outreach. |
|
|
429
|
-
| `li_post_comments` | `post_id` required; `comment_id` optional exact parent id; use the exact `social_id` from `li_my_posts`; `limit` default 50, max 100; `cursor` optional | Read one bounded page of comments, or pass `comment_id` to read replies to that exact parent. Always inspect `completeness.state`, `total`, and `next_cursor`; continue with the cursor while state is `incomplete`. Numeric ids are resolved against the user's own recent posts when possible. The provider total is reported when available and is never guessed from `li_my_posts`. |
|
|
446
|
+
| `li_post_comments` | `post_id` required; `comment_id` optional exact parent id; `resolve_reply_state` optional boolean, not combinable with `comment_id`; use the exact `social_id` from `li_my_posts`; `limit` default 50, max 100; `cursor` optional | Read one bounded page of comments, or pass `comment_id` to read replies to that exact parent. Pass `resolve_reply_state: true` before replying to anything: each comment then carries `reply_state.replied_by_me`. Skip every comment whose `replied_by_me` is `true`, and skip every comment whose `replied_by_me` is `null`, which means unknown and never means nobody replied. Only `no_replies` and `answered_by_others` are proven unanswered. Always inspect `completeness.state`, `total`, and `next_cursor`; continue with the cursor while state is `incomplete`. Also inspect `reply_resolution.state`; `partial` or `aborted` means some comments were never resolved. Numeric ids are resolved against the user's own recent posts when possible. The provider total is reported when available and is never guessed from `li_my_posts`. |
|
|
430
447
|
| `li_reply_to_comment` | Manual path: `post_id`, `parent_comment_id`, `trigger_comment_id`, and `text` required; `expected_watermark` optional exact 64-character watermark. Secretary path: `secretary_receipt_id` alone. | Reply once to one exact inbound comment on this sender's own post after `li_post_comments`. SignalDash re-proves post ownership, the unchanged trigger, its author and parent, no own duplicate, sender generation, budget, and provider health immediately before writing. A 2xx is not success until readback finds exactly one matching own reply. |
|
|
431
448
|
| `li_like_comment` | `post_id`, `parent_comment_id`, and `comment_id` required; `expected_watermark` optional exact 64-character watermark | Like once one exact inbound comment on this sender's own post after `li_post_comments`. SignalDash re-proves post ownership, the unchanged comment, its author and parent, no own like, sender generation, budget, and provider health immediately before writing. A 2xx is not success until bounded readback finds exactly one own like. |
|
|
432
|
-
| `li_delete_message` | `chat_id`, `message_id`, and `confirm:true` required | Remediate one exact own LinkedIn message within 60 minutes of sending. SignalDash proves account, exact chat, own authorship, timestamp eligibility, a separate remediation budget, and post-delete
|
|
449
|
+
| `li_delete_message` | `chat_id`, `message_id`, and `confirm:true` required | Remediate one exact own LinkedIn message within 60 minutes of sending. SignalDash proves account, exact chat, own authorship, timestamp eligibility, a separate remediation budget, and the provider's post-delete state. Success requires `deleted:1` or a genuine 404. A present row or failed readback returns `502 deleted_unconfirmed`, locks the sender, and is never retried automatically. This cannot undo delivery, reading, or notifications and never relaxes a send gate. |
|
|
433
450
|
| `li_delete_comment` | `post_id`, `comment_id`, and `confirm:true` required | Currently unavailable: the fixture-tested Unipile v2 wrapper is held at database capability state `untested` until live compatibility is proved against Federico's own removable comment. When enabled it proves own comment identity and readback. Deletion is remediation, not rollback. |
|
|
434
|
-
| `li_draft_post` | `text` required, max 3000; `publish` optional; `scheduled_at` optional offset-qualified ISO date-time; `content_pipeline_id` required for scheduling while that user's pipeline is enabled; `mentions` optional array of up to 20 exact `{name,profile_id}` objects; `attachments` optional array of up to 4 exact `{filename,content_type,content_base64}` images; `first_comment` optional, max 1250 | Create a server-confirmed draft, publish now, or persist an exact future LinkedIn post and its approved first comment. An enabled pipeline runs its fact, exact-text, shared-calendar, frequency, and breathing preflight
|
|
451
|
+
| `li_draft_post` | `text` required, max 3000; `publish` optional; `scheduled_at` optional offset-qualified ISO date-time; `content_pipeline_id` normally required for scheduling while that user's pipeline is enabled; `content_pipeline_override` optional exact `{reason,confirm?,approval_hash?}`; `mentions` optional array of up to 20 exact `{name,profile_id}` objects; `attachments` optional array of up to 4 exact `{filename,content_type,content_base64}` images; `first_comment` optional, max 1250 | Create a server-confirmed draft, publish now, or persist an exact future LinkedIn post and its approved first comment. An enabled pipeline normally runs its fact, exact-text, shared-calendar, frequency, and breathing preflight. For one deliberate exception, preview the exact payload with `{reason}`, show it to the human, then repeat it with `confirm:true` and the returned single-use payload-bound `approval_hash`. The audit record preserves the reason, preview ID, and approval time; every non-pipeline provider safety guard remains active. |
|
|
435
452
|
| `li_set_scheduled_post_first_comment` | `id` required UUID; `first_comment` required, max 1250; `confirm:true` required | Attach one exact approved first comment to a scheduled post. SignalDash publishes it through the same connected account after the post and never republishes the post if the comment fails. |
|
|
436
453
|
| `li_scheduled_posts` | no arguments | Read one shared content-calendar view across SignalDash and the configured Buffer LinkedIn channel. Inspect `completeness` and every `sources.*.state` before treating absence as an empty calendar. Native LinkedIn scheduled posts and drafts are invisible because Unipile has no documented read route for them; SignalDash does not use raw Voyager routes or linkedin.com browser access. An empty `items` array proves only that the visible sources returned no entries. |
|
|
437
454
|
| `li_cancel_scheduled_post` | `id` required UUID; `confirm:true` required | Cancel one exact post while it is still scheduled. It cannot recall an executing or published post. |
|
|
@@ -527,7 +544,14 @@ approval, repeat the identical payload with `publish:true`. Verify the stored
|
|
|
527
544
|
record with `li_scheduled_posts`. Before scheduling, read that shared view and
|
|
528
545
|
inspect its `completeness` plus the SignalDash, Buffer, and native LinkedIn
|
|
529
546
|
source states. A date conflict in either visible source blocks planning until
|
|
530
|
-
the human resolves it.
|
|
547
|
+
the human resolves it. If the human explicitly approves one exception, call
|
|
548
|
+
the preview again with `content_pipeline_override:{reason}`, show the returned
|
|
549
|
+
exact payload, time, comment, reason, and expiry, then repeat that identical
|
|
550
|
+
payload once with `publish:true`, `confirm:true`, and its `approval_hash`. Never
|
|
551
|
+
generate an override silently, reuse its single-use hash, change its reason, or
|
|
552
|
+
disable the pipeline as a shortcut. The schedule and approval consumption
|
|
553
|
+
commit atomically. Once scheduled, its override-bound first comment is
|
|
554
|
+
immutable. Native LinkedIn remains explicitly blind, so never call
|
|
531
555
|
an empty result an empty calendar. For an existing scheduled post, use
|
|
532
556
|
`li_set_scheduled_post_first_comment` only after approval of the exact comment.
|
|
533
557
|
SignalDash persists the published post ID before sending the comment, so a
|
|
@@ -704,6 +728,16 @@ never retried and never causes the post to be published again. Use
|
|
|
704
728
|
requires a fresh list, the exact id, approval, and
|
|
705
729
|
`confirm:true`; it works only while state is `scheduled`.
|
|
706
730
|
|
|
731
|
+
An explicit `content_pipeline_override` is scoped to one exact scheduled
|
|
732
|
+
payload and expires after 15 minutes. The preview hash binds the post text,
|
|
733
|
+
offset-qualified instant, mention set, image filenames/types/bytes, first
|
|
734
|
+
comment, and exact reason. It bypasses only the enabled content pipeline's
|
|
735
|
+
idea/arc/fact/calendar/frequency/breathing preflight for that one schedule. It
|
|
736
|
+
commits atomically with the schedule, and its bound first comment cannot be
|
|
737
|
+
edited afterward. It
|
|
738
|
+
does not bypass sender resolution, duplicate protection, provider warnings,
|
|
739
|
+
locks, budgets, publication ordering, or first-comment failure handling.
|
|
740
|
+
|
|
707
741
|
Everything SignalDash can schedule is one exact thing at one exact time:
|
|
708
742
|
`li_draft_post` for a post, `sd_schedule_message` for a message. There is no
|
|
709
743
|
recurring schedule, no automatic follow-up, no acceptance-triggered message and
|
|
@@ -1266,6 +1300,25 @@ Comply:
|
|
|
1266
1300
|
Any upstream 429, provider warning, checkpoint, restriction, unusual-activity
|
|
1267
1301
|
prompt, or HTTP 403 also means stop. Do not retry.
|
|
1268
1302
|
|
|
1303
|
+
### 502 `upstream_authentication_failed`
|
|
1304
|
+
|
|
1305
|
+
Meaning: the provider definitively rejected authentication. The result includes
|
|
1306
|
+
`upstream_status` (401 or 403) and is distinct from `outcome_unknown`; a 401 is
|
|
1307
|
+
not reported as a timeout. Stop sends on that channel and reconnect or escalate
|
|
1308
|
+
the provider credential. `signaldash status` reports the channel as unavailable
|
|
1309
|
+
after a send-path 401. An explicit new account claim clears the recorded failure;
|
|
1310
|
+
a later confirmed WhatsApp or email send also clears it. LinkedIn 401 durably
|
|
1311
|
+
locks its sender, and `sd_budget_status` reports that lock with zero actions
|
|
1312
|
+
available now.
|
|
1313
|
+
|
|
1314
|
+
Every provider failure from a send includes a `request_id` that is also present
|
|
1315
|
+
in the server log beside only the operation and channel. Use it for operator
|
|
1316
|
+
correlation; no chat identifier, account identifier, recipient, or message text
|
|
1317
|
+
is logged. If an intermediary removes an error body, the MCP client reconstructs
|
|
1318
|
+
the safe classification from response headers. With no usable body or headers it
|
|
1319
|
+
returns `backend_error_response_unreadable` with `outcome_unknown:true`, never
|
|
1320
|
+
an empty object and never permission to retry.
|
|
1321
|
+
|
|
1269
1322
|
## Before every send
|
|
1270
1323
|
|
|
1271
1324
|
These rules apply to LinkedIn messages and invitations, WhatsApp, email, and
|
|
@@ -1447,9 +1500,10 @@ If the user then asks to take that message back:
|
|
|
1447
1500
|
wa_delete_message({"chat_id":"chat_wa_91b2","message_id":"msg_wa_5c71"})
|
|
1448
1501
|
```
|
|
1449
1502
|
|
|
1450
|
-
3.
|
|
1451
|
-
|
|
1452
|
-
|
|
1503
|
+
3. Require `deleted:true` and `readback_verified:true` in the result. SignalDash
|
|
1504
|
+
performs the provider re-read itself. `502 deleted_unconfirmed` means the
|
|
1505
|
+
provider accepted the delete but the readback did not prove it gone; do not
|
|
1506
|
+
retry that message.
|
|
1453
1507
|
4. Never delete a message the user did not name. `403 message_not_own` means
|
|
1454
1508
|
the message is the other person's and cannot be removed by anyone,
|
|
1455
1509
|
including a group admin. `409 duplicate_delete` means SignalDash already
|