Skip to content
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
62 changes: 61 additions & 1 deletion src/confirmations.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -1065,14 +1065,48 @@ export function composeAnnouncers(map) {
//
// Logs go to stderr only (stdout is the MCP stdio protocol channel — writing
// there would corrupt it). Returns the interval handle so callers can stop it.
export function startChatReplyPoller({ apiKey, room, intervalMs = 5000, log }) {
// `owner` is the account handle decisions are accepted from. It also decides
// who is worth ANSWERING when a decision is rejected: see `ownerish` below.
// Defaulting it keeps existing callers behaving identically, but it is a
// parameter rather than a literal because this ships as a product and the
// owner is not always called petrus.
export function startChatReplyPoller({ apiKey, room, intervalMs = 5000, log, owner = 'petrus' }) {
if (!apiKey || !room) {
process.stderr.write('[iak-mcp] chat-reply poller: missing apiKey or room — disabled\n');
return null;
}
const emit = log || ((msg) => process.stderr.write(`[iak-mcp] ${msg}\n`));
const seen = new Set();
let primed = false;
// A dropped decision has to be VISIBLE, not merely logged. On 2026-08-03
// petrus typed "/approve f2af1c66" from his tablet; the owner guard below
// rejected it because that device posts as "@petrus-boox" rather than
// "petrus", wrote one line to stderr, and left the intent pending. He saw
// no error, assumed the approval had landed, and moved on — the approval
// path failing in the one way it must never fail, silently. Every reject
// branch now answers in the room. Costs one request per rejected message,
// and `seen` guarantees that is once, not once per poll.
//
// No feedback loop: these replies never match the /approve|/deny regex
// below, which is anchored at the start of the message.
//
// Backticks are stripped from interpolated handles: a handle is chosen by
// whoever registered it, and these strings put it inside a markdown code
// span, which one backtick would break out of.
const reply = async (body) => {
try {
await fetch('https://groupmind.one/api/v1/messages', {
method: 'POST',
headers: { 'X-API-Key': apiKey, 'Content-Type': 'application/json' },
body: JSON.stringify({ room, body }),
});
Comment on lines +1098 to +1102

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Handle non-2xx reply responses

When GroupMind returns an HTTP error such as 401, 429, or 5xx, fetch still fulfills normally, so this reply is treated as successful and reply failed is never emitted. Because the rejected command was already added to seen, it is not retried either, leaving the user with the same silent rejection this change is intended to prevent; check res.ok and treat a non-success status as a failed reply.

Useful? React with 👍 / 👎.

} catch (e) {
// Never let a failed reply break the poll loop: not telling someone
// their tap was rejected is bad, but dropping every later tap on the
// floor because one POST failed is worse.
emit(`reply failed: ${e.message}`);
}
};
const poll = async () => {
try {
const url = `https://groupmind.one/api/v1/rooms/${encodeURIComponent(room)}/messages?limit=30`;
Expand All @@ -1095,13 +1129,39 @@ export function startChatReplyPoller({ apiKey, room, intervalMs = 5000, log }) {
const sender = String(m.from || '').replace(/^@/, '').toLowerCase();
if (sender !== 'petrus' && m.isHuman !== true) {
emit(`${text} from ${m.from}: sender is not the owner — ignoring`);
// Answer only senders who plausibly ARE the owner (`petrus`,
// `petrus-boox`, a future `petrus-watch`). claudeMB's review caught
// that replying to everything amplifies the very misbehaviour this
// guard was written for: a fleet agent once retried `/approve` in a
// loop, and answering each attempt would turn a silent log line into
// the daemon spamming the room — which is petrus's phone notification
// surface. Worse, a bot that retries on being told "not recorded"
// ping-pongs forever, and no `seen` set stops that because every
// round is a genuinely new message id.
//
// A human who tapped Approve needs to know it did not land. An agent
// emitting a spurious `/approve` does not; the log line was always
// the right answer for it.
const ownerish = sender === owner || sender.startsWith(`${owner}-`);
if (ownerish) {
await reply(
`\`${text}\` was NOT recorded — the intent is still pending. ` +
`Only the account owner can settle intents, and this arrived from ` +
`\`${String(m.from || '').replace(/`/g, '')}\`, which is not a ` +
`recognised owner identity.`
);
}
continue;
}
const decision = match[1].toLowerCase();
const id = match[2];
const intent = getIntent(id);
if (!intent) {
emit(`/${decision} ${id} from ${m.from}: unknown intent, ignoring`);
await reply(
`\`/${decision} ${id}\` was NOT recorded — no intent with that id. ` +
`It has probably expired or been settled already.`
);
continue;
}
const r = decideIntent(id, decision);
Expand Down
Loading