ping-a-human 0.1.0
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/LICENSE +21 -0
- package/README.md +146 -0
- package/dist/channel-factory.d.ts +25 -0
- package/dist/channel-factory.js +19 -0
- package/dist/channel.d.ts +64 -0
- package/dist/channel.js +10 -0
- package/dist/channels/telegram.d.ts +37 -0
- package/dist/channels/telegram.js +118 -0
- package/dist/config.d.ts +54 -0
- package/dist/config.js +80 -0
- package/dist/index.d.ts +12 -0
- package/dist/index.js +71 -0
- package/dist/setup.d.ts +67 -0
- package/dist/setup.js +198 -0
- package/dist/tools.d.ts +33 -0
- package/dist/tools.js +50 -0
- package/package.json +48 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 ping-a-human contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
# ping-a-human
|
|
2
|
+
|
|
3
|
+
An open-source [Model Context Protocol (MCP)](https://modelcontextprotocol.io) server that adds a
|
|
4
|
+
human-in-the-loop step to any AI pipeline, agent, or automation. It lets an AI **notify** a human or
|
|
5
|
+
**ask** a human and wait for their answer — reaching the person on the messaging app they already use
|
|
6
|
+
(Telegram first; Slack, WhatsApp, and more later).
|
|
7
|
+
|
|
8
|
+
Unlike MCP's built-in elicitation (which prompts inside the AI client UI), `ping-a-human` reaches the
|
|
9
|
+
human **out-of-band on their own messaging app**, so it works even when nobody is watching the AI
|
|
10
|
+
session.
|
|
11
|
+
|
|
12
|
+
## Tools
|
|
13
|
+
|
|
14
|
+
The server exposes two tools over stdio:
|
|
15
|
+
|
|
16
|
+
- **`notify_human`** — fire-and-forget. Sends a message to the configured human and returns
|
|
17
|
+
immediately without waiting for a reply.
|
|
18
|
+
- Input: `{ message: string }`
|
|
19
|
+
- **`ask_human`** — sends a question, then **blocks until the human replies** (or a timeout elapses)
|
|
20
|
+
and returns their answer.
|
|
21
|
+
- Input: `{ question: string, choices?: string[], timeoutMs?: number }`
|
|
22
|
+
- With `choices`, the options render as tappable Telegram inline buttons and the tapped value is
|
|
23
|
+
returned (e.g. `["Yes", "No"]`).
|
|
24
|
+
- On timeout it returns a clear timed-out result instead of an error, so the calling agent gets a
|
|
25
|
+
clean signal. Default timeout is 5 minutes.
|
|
26
|
+
|
|
27
|
+
## Install
|
|
28
|
+
|
|
29
|
+
No clone or build required. The server runs straight from npm via your package runner:
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
npx ping-a-human setup # npm
|
|
33
|
+
bunx ping-a-human setup # bun
|
|
34
|
+
pnpm dlx ping-a-human setup # pnpm
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
Your MCP client (Claude Desktop, Cursor, etc.) launches it the same way (see step 2) — so end users
|
|
38
|
+
never install anything globally. Cloning the repo is only needed for contributing.
|
|
39
|
+
|
|
40
|
+
## Quickstart
|
|
41
|
+
|
|
42
|
+
### 1. Create a Telegram bot and configure the server
|
|
43
|
+
|
|
44
|
+
Run the interactive setup wizard — it validates your bot token, auto-detects your chat id, writes the
|
|
45
|
+
config, and prints the MCP client entry to paste:
|
|
46
|
+
|
|
47
|
+
```bash
|
|
48
|
+
npx ping-a-human setup
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
The wizard walks you through:
|
|
52
|
+
|
|
53
|
+
1. Message [@BotFather](https://t.me/BotFather) in Telegram, send `/newbot`, and copy the bot token.
|
|
54
|
+
2. Paste the token when prompted — the wizard verifies it via Telegram and shows your bot's `@username`.
|
|
55
|
+
3. Send your new bot any message (e.g. "hi") in Telegram, then press Enter — the wizard auto-detects
|
|
56
|
+
your `chat_id`.
|
|
57
|
+
4. The config is saved to `~/.config/ping-a-human/config.json` and the wizard prints an `mcpServers`
|
|
58
|
+
snippet.
|
|
59
|
+
|
|
60
|
+
### 2. Add the server to your MCP client
|
|
61
|
+
|
|
62
|
+
Add this to your MCP client config (Claude Desktop, Cursor, etc.):
|
|
63
|
+
|
|
64
|
+
```json
|
|
65
|
+
{
|
|
66
|
+
"mcpServers": {
|
|
67
|
+
"ping-a-human": {
|
|
68
|
+
"command": "npx",
|
|
69
|
+
"args": ["-y", "ping-a-human"]
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
Restart the client. It will list `notify_human` and `ask_human`.
|
|
76
|
+
|
|
77
|
+
### 3. Use it from an agent
|
|
78
|
+
|
|
79
|
+
- **Notify** when a long job finishes: `notify_human({ message: "Deploy to prod finished ✅" })`.
|
|
80
|
+
- **Ask before a risky action**:
|
|
81
|
+
`ask_human({ question: "Apply this DB migration to prod?", choices: ["Yes", "No"] })` — the human
|
|
82
|
+
taps a button and the agent receives `"Yes"` or `"No"`.
|
|
83
|
+
- **Ask an open question**: `ask_human({ question: "What should the release title be?" })` — the agent
|
|
84
|
+
receives the human's free-text reply.
|
|
85
|
+
|
|
86
|
+
## Configuration
|
|
87
|
+
|
|
88
|
+
The server reads configuration with this precedence:
|
|
89
|
+
|
|
90
|
+
1. Environment variables `TELEGRAM_BOT_TOKEN` and `TELEGRAM_CHAT_ID` (both required together).
|
|
91
|
+
2. A config file at `$PING_A_HUMAN_CONFIG`, or `~/.config/ping-a-human/config.json` by default,
|
|
92
|
+
shaped as:
|
|
93
|
+
|
|
94
|
+
```json
|
|
95
|
+
{ "telegram": { "botToken": "123456:ABC-...", "chatId": "4242" } }
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
The bot token is a secret — it is never logged or echoed. All diagnostics go to **stderr**; **stdout**
|
|
99
|
+
is reserved for the MCP JSON-RPC channel.
|
|
100
|
+
|
|
101
|
+
## Local development
|
|
102
|
+
|
|
103
|
+
```bash
|
|
104
|
+
npm install
|
|
105
|
+
npm run build
|
|
106
|
+
node dist/index.js # starts the stdio MCP server
|
|
107
|
+
node dist/index.js setup # runs the setup wizard
|
|
108
|
+
npm test # runs the full test suite
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
### Live smoke test (optional)
|
|
112
|
+
|
|
113
|
+
With real credentials set, send a real message and wait for a reply:
|
|
114
|
+
|
|
115
|
+
```bash
|
|
116
|
+
TELEGRAM_BOT_TOKEN=... TELEGRAM_CHAT_ID=... node scripts/smoke-telegram.mjs
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
Without credentials it prints a skip notice and exits 0, so it is safe in CI.
|
|
120
|
+
|
|
121
|
+
## End-to-end verification (live)
|
|
122
|
+
|
|
123
|
+
A repeatable manual procedure that exercises the whole loop against real Telegram:
|
|
124
|
+
|
|
125
|
+
1. **Create a bot.** In Telegram, message [@BotFather](https://t.me/BotFather), send `/newbot`, and
|
|
126
|
+
copy the token.
|
|
127
|
+
2. **Run setup.** `npx ping-a-human setup` — paste the token, message your bot when prompted, and let
|
|
128
|
+
the wizard auto-detect your `chat_id` and write the config.
|
|
129
|
+
3. **Wire up a client.** Add the printed `mcpServers` snippet to Claude Desktop / Cursor and restart.
|
|
130
|
+
4. **Prove `ask_human` with buttons.** From the client, call
|
|
131
|
+
`ask_human({ question: "Proceed?", choices: ["Yes", "No"] })`. You should receive a Telegram message
|
|
132
|
+
with two buttons; tap one and confirm the agent receives that exact value.
|
|
133
|
+
5. **Prove `notify_human`.** Call `notify_human({ message: "hello from my agent" })` and confirm the
|
|
134
|
+
message arrives and the call returns immediately.
|
|
135
|
+
6. **Prove the timeout.** Call `ask_human({ question: "...", timeoutMs: 10000 })` and do not reply;
|
|
136
|
+
after ~10s the agent should receive a clear timed-out result (not an error).
|
|
137
|
+
|
|
138
|
+
No MCP client handy? The credential-gated smoke script exercises the live send → reply path directly:
|
|
139
|
+
|
|
140
|
+
```bash
|
|
141
|
+
TELEGRAM_BOT_TOKEN=... TELEGRAM_CHAT_ID=... node scripts/smoke-telegram.mjs
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
## License
|
|
145
|
+
|
|
146
|
+
MIT — see [LICENSE](./LICENSE).
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type { Channel } from "./channel.js";
|
|
2
|
+
import { type LoadConfigOptions } from "./config.js";
|
|
3
|
+
/**
|
|
4
|
+
* Single seam where resolved configuration meets a concrete channel provider.
|
|
5
|
+
*
|
|
6
|
+
* Tools (notify_human / ask_human) depend only on the {@link Channel}
|
|
7
|
+
* interface; this factory is the one place that knows Telegram is the current
|
|
8
|
+
* implementation (R005). Adding Slack/WhatsApp later means branching here on a
|
|
9
|
+
* provider field — tool logic stays untouched.
|
|
10
|
+
*/
|
|
11
|
+
export type CreateChannelOptions = LoadConfigOptions & {
|
|
12
|
+
/**
|
|
13
|
+
* Pre-built channel override. When provided it is returned as-is, bypassing
|
|
14
|
+
* config loading. Used by tests to inject a stub Channel.
|
|
15
|
+
*/
|
|
16
|
+
channel?: Channel;
|
|
17
|
+
};
|
|
18
|
+
/**
|
|
19
|
+
* Build a {@link Channel} from configuration.
|
|
20
|
+
*
|
|
21
|
+
* Throws (via loadConfig) with a clear, secret-free message when configuration
|
|
22
|
+
* is missing or invalid. Callers should surface that as a tool result rather
|
|
23
|
+
* than crashing the server.
|
|
24
|
+
*/
|
|
25
|
+
export declare function createChannel(opts?: CreateChannelOptions): Channel;
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { loadConfig } from "./config.js";
|
|
2
|
+
import { TelegramChannel } from "./channels/telegram.js";
|
|
3
|
+
/**
|
|
4
|
+
* Build a {@link Channel} from configuration.
|
|
5
|
+
*
|
|
6
|
+
* Throws (via loadConfig) with a clear, secret-free message when configuration
|
|
7
|
+
* is missing or invalid. Callers should surface that as a tool result rather
|
|
8
|
+
* than crashing the server.
|
|
9
|
+
*/
|
|
10
|
+
export function createChannel(opts = {}) {
|
|
11
|
+
if (opts.channel) {
|
|
12
|
+
return opts.channel;
|
|
13
|
+
}
|
|
14
|
+
const config = loadConfig(opts);
|
|
15
|
+
return new TelegramChannel({
|
|
16
|
+
botToken: config.telegram.botToken,
|
|
17
|
+
chatId: config.telegram.chatId,
|
|
18
|
+
});
|
|
19
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Transport-agnostic messaging channel contract.
|
|
3
|
+
*
|
|
4
|
+
* This interface is deliberately free of any provider-specific types so that
|
|
5
|
+
* the first messaging provider is just one implementation and others (Slack,
|
|
6
|
+
* WhatsApp) can be added later without changing tool logic (R005). Tools
|
|
7
|
+
* (notify_human / ask_human) import ONLY this module — never a concrete
|
|
8
|
+
* channel's types.
|
|
9
|
+
*/
|
|
10
|
+
/** Opaque handle to a message that was sent through a channel. */
|
|
11
|
+
export type MessageRef = {
|
|
12
|
+
/** Provider-neutral message identifier (the provider's message id as a string). */
|
|
13
|
+
id: string;
|
|
14
|
+
};
|
|
15
|
+
/** Options controlling how a message is sent. */
|
|
16
|
+
export type SendOptions = {
|
|
17
|
+
/**
|
|
18
|
+
* Optional predefined choices. When present, the channel should render them
|
|
19
|
+
* as tappable buttons and return the selected value as the reply answer.
|
|
20
|
+
*/
|
|
21
|
+
choices?: string[];
|
|
22
|
+
};
|
|
23
|
+
/** Options controlling how long, and from when, to wait for a human reply. */
|
|
24
|
+
export type AwaitReplyOptions = {
|
|
25
|
+
/** Overall deadline in milliseconds. On expiry, awaitReply resolves to a timeout result. */
|
|
26
|
+
timeoutMs: number;
|
|
27
|
+
/** Optional anchor: only consider replies that arrive after this sent message. */
|
|
28
|
+
sinceRef?: MessageRef;
|
|
29
|
+
};
|
|
30
|
+
/** The human who answered. Neutral identity — no provider-specific fields. */
|
|
31
|
+
export type Respondent = {
|
|
32
|
+
/** Provider-neutral respondent identifier. */
|
|
33
|
+
id: string;
|
|
34
|
+
/** Best-effort display name, when available. */
|
|
35
|
+
name?: string;
|
|
36
|
+
};
|
|
37
|
+
/**
|
|
38
|
+
* Result of awaiting a human reply. A discriminated union so callers must
|
|
39
|
+
* handle the timeout case explicitly instead of treating it as an error.
|
|
40
|
+
*/
|
|
41
|
+
export type ReplyResult = {
|
|
42
|
+
status: "answered";
|
|
43
|
+
answer: string;
|
|
44
|
+
respondent: Respondent;
|
|
45
|
+
} | {
|
|
46
|
+
status: "timeout";
|
|
47
|
+
};
|
|
48
|
+
/**
|
|
49
|
+
* A messaging channel that can deliver a message to a human and (optionally)
|
|
50
|
+
* wait for their reply.
|
|
51
|
+
*/
|
|
52
|
+
export interface Channel {
|
|
53
|
+
/**
|
|
54
|
+
* Deliver a message. Returns a reference to the sent message. Does not wait
|
|
55
|
+
* for a reply — use awaitReply for that.
|
|
56
|
+
*/
|
|
57
|
+
send(message: string, options?: SendOptions): Promise<MessageRef>;
|
|
58
|
+
/**
|
|
59
|
+
* Wait for the next human reply (free text or a chosen option) until the
|
|
60
|
+
* deadline. Resolves to an answered result or a timeout result; it does not
|
|
61
|
+
* throw on timeout.
|
|
62
|
+
*/
|
|
63
|
+
awaitReply(options: AwaitReplyOptions): Promise<ReplyResult>;
|
|
64
|
+
}
|
package/dist/channel.js
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Transport-agnostic messaging channel contract.
|
|
3
|
+
*
|
|
4
|
+
* This interface is deliberately free of any provider-specific types so that
|
|
5
|
+
* the first messaging provider is just one implementation and others (Slack,
|
|
6
|
+
* WhatsApp) can be added later without changing tool logic (R005). Tools
|
|
7
|
+
* (notify_human / ask_human) import ONLY this module — never a concrete
|
|
8
|
+
* channel's types.
|
|
9
|
+
*/
|
|
10
|
+
export {};
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import type { AwaitReplyOptions, Channel, MessageRef, ReplyResult, SendOptions } from "../channel.js";
|
|
2
|
+
type FetchImpl = typeof fetch;
|
|
3
|
+
export type TelegramChannelConfig = {
|
|
4
|
+
botToken: string;
|
|
5
|
+
chatId: string;
|
|
6
|
+
/** Injectable for tests; defaults to the global fetch. */
|
|
7
|
+
fetchImpl?: FetchImpl;
|
|
8
|
+
/** Server-side long-poll seconds per getUpdates call. Tests use 0 for speed. */
|
|
9
|
+
longPollSeconds?: number;
|
|
10
|
+
};
|
|
11
|
+
/**
|
|
12
|
+
* Telegram implementation of the Channel interface using the Bot API over
|
|
13
|
+
* plain HTTPS (Node's built-in fetch). No third-party Telegram SDK.
|
|
14
|
+
*
|
|
15
|
+
* Reply capture uses getUpdates long-polling (no webhook / public URL needed).
|
|
16
|
+
* Both free-text replies (update.message) and inline-button taps
|
|
17
|
+
* (update.callback_query) resolve awaitReply. The getUpdates offset cursor is
|
|
18
|
+
* owned per instance and always advanced to update_id + 1 to avoid redelivery.
|
|
19
|
+
*/
|
|
20
|
+
export declare class TelegramChannel implements Channel {
|
|
21
|
+
private readonly botToken;
|
|
22
|
+
private readonly chatId;
|
|
23
|
+
private readonly fetchImpl;
|
|
24
|
+
private readonly longPollSeconds;
|
|
25
|
+
/** getUpdates cursor: next update offset to request. */
|
|
26
|
+
private offset;
|
|
27
|
+
/** Maps short callback_data tokens back to human-readable choice values. */
|
|
28
|
+
private callbackChoices;
|
|
29
|
+
constructor(config: TelegramChannelConfig);
|
|
30
|
+
/** POST a JSON body to a Bot API method. Never logs or echoes the token. */
|
|
31
|
+
private api;
|
|
32
|
+
send(message: string, options?: SendOptions): Promise<MessageRef>;
|
|
33
|
+
awaitReply(options: AwaitReplyOptions): Promise<ReplyResult>;
|
|
34
|
+
private fromConfiguredChat;
|
|
35
|
+
private respondentFrom;
|
|
36
|
+
}
|
|
37
|
+
export {};
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Telegram implementation of the Channel interface using the Bot API over
|
|
3
|
+
* plain HTTPS (Node's built-in fetch). No third-party Telegram SDK.
|
|
4
|
+
*
|
|
5
|
+
* Reply capture uses getUpdates long-polling (no webhook / public URL needed).
|
|
6
|
+
* Both free-text replies (update.message) and inline-button taps
|
|
7
|
+
* (update.callback_query) resolve awaitReply. The getUpdates offset cursor is
|
|
8
|
+
* owned per instance and always advanced to update_id + 1 to avoid redelivery.
|
|
9
|
+
*/
|
|
10
|
+
export class TelegramChannel {
|
|
11
|
+
botToken;
|
|
12
|
+
chatId;
|
|
13
|
+
fetchImpl;
|
|
14
|
+
longPollSeconds;
|
|
15
|
+
/** getUpdates cursor: next update offset to request. */
|
|
16
|
+
offset;
|
|
17
|
+
/** Maps short callback_data tokens back to human-readable choice values. */
|
|
18
|
+
callbackChoices = new Map();
|
|
19
|
+
constructor(config) {
|
|
20
|
+
this.botToken = config.botToken;
|
|
21
|
+
this.chatId = config.chatId;
|
|
22
|
+
this.fetchImpl = config.fetchImpl ?? fetch;
|
|
23
|
+
this.longPollSeconds = config.longPollSeconds ?? 30;
|
|
24
|
+
}
|
|
25
|
+
/** POST a JSON body to a Bot API method. Never logs or echoes the token. */
|
|
26
|
+
async api(method, body) {
|
|
27
|
+
const url = `https://api.telegram.org/bot${this.botToken}/${method}`;
|
|
28
|
+
const res = await this.fetchImpl(url, {
|
|
29
|
+
method: "POST",
|
|
30
|
+
headers: { "content-type": "application/json" },
|
|
31
|
+
body: JSON.stringify(body),
|
|
32
|
+
});
|
|
33
|
+
const json = (await res.json());
|
|
34
|
+
if (!json.ok) {
|
|
35
|
+
// Reference the method, not the token, in the error.
|
|
36
|
+
throw new Error(`Telegram API ${method} failed: ${json.description ?? "unknown error"}`);
|
|
37
|
+
}
|
|
38
|
+
return json.result;
|
|
39
|
+
}
|
|
40
|
+
async send(message, options) {
|
|
41
|
+
const body = {
|
|
42
|
+
chat_id: this.chatId,
|
|
43
|
+
text: message,
|
|
44
|
+
};
|
|
45
|
+
if (options?.choices && options.choices.length > 0) {
|
|
46
|
+
// Build inline buttons. callback_data is capped at 64 bytes by Telegram,
|
|
47
|
+
// so use a short index token and map it back to the choice value.
|
|
48
|
+
this.callbackChoices.clear();
|
|
49
|
+
const row = options.choices.map((choice, i) => {
|
|
50
|
+
const token = `c${i}`;
|
|
51
|
+
this.callbackChoices.set(token, choice);
|
|
52
|
+
return { text: choice, callback_data: token };
|
|
53
|
+
});
|
|
54
|
+
body.reply_markup = { inline_keyboard: [row] };
|
|
55
|
+
}
|
|
56
|
+
const result = await this.api("sendMessage", body);
|
|
57
|
+
return { id: String(result.message_id) };
|
|
58
|
+
}
|
|
59
|
+
async awaitReply(options) {
|
|
60
|
+
const deadline = Date.now() + options.timeoutMs;
|
|
61
|
+
while (Date.now() < deadline) {
|
|
62
|
+
const remainingMs = deadline - Date.now();
|
|
63
|
+
// Don't long-poll longer than the time we have left.
|
|
64
|
+
const pollSeconds = Math.max(0, Math.min(this.longPollSeconds, Math.floor(remainingMs / 1000)));
|
|
65
|
+
const updates = await this.api("getUpdates", {
|
|
66
|
+
offset: this.offset,
|
|
67
|
+
timeout: pollSeconds,
|
|
68
|
+
allowed_updates: ["message", "callback_query"],
|
|
69
|
+
});
|
|
70
|
+
for (const update of updates) {
|
|
71
|
+
// Always advance the cursor so updates are not redelivered.
|
|
72
|
+
this.offset = update.update_id + 1;
|
|
73
|
+
// Free-text reply.
|
|
74
|
+
const msg = update.message;
|
|
75
|
+
if (msg?.text && this.fromConfiguredChat(msg.chat)) {
|
|
76
|
+
return {
|
|
77
|
+
status: "answered",
|
|
78
|
+
answer: msg.text,
|
|
79
|
+
respondent: this.respondentFrom(msg.from),
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
// Inline-button tap.
|
|
83
|
+
const cb = update.callback_query;
|
|
84
|
+
if (cb) {
|
|
85
|
+
// Best-effort: clear the client's loading spinner.
|
|
86
|
+
try {
|
|
87
|
+
await this.api("answerCallbackQuery", { callback_query_id: cb.id });
|
|
88
|
+
}
|
|
89
|
+
catch {
|
|
90
|
+
// Non-fatal; the answer was still captured.
|
|
91
|
+
}
|
|
92
|
+
const answer = (cb.data && this.callbackChoices.get(cb.data)) ?? cb.data ?? "";
|
|
93
|
+
if (this.fromConfiguredChat(cb.message?.chat) || cb.message == null) {
|
|
94
|
+
return {
|
|
95
|
+
status: "answered",
|
|
96
|
+
answer,
|
|
97
|
+
respondent: this.respondentFrom(cb.from),
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
return { status: "timeout" };
|
|
104
|
+
}
|
|
105
|
+
fromConfiguredChat(chat) {
|
|
106
|
+
if (!chat)
|
|
107
|
+
return false;
|
|
108
|
+
return String(chat.id) === this.chatId;
|
|
109
|
+
}
|
|
110
|
+
respondentFrom(user) {
|
|
111
|
+
if (!user)
|
|
112
|
+
return { id: "unknown" };
|
|
113
|
+
return {
|
|
114
|
+
id: String(user.id),
|
|
115
|
+
name: user.first_name ?? user.username,
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
}
|
package/dist/config.d.ts
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
/**
|
|
3
|
+
* Persisted configuration shape. The setup wizard (S04) writes this exact
|
|
4
|
+
* structure, and the channel layer reads it. Validated with zod so malformed
|
|
5
|
+
* config fails loudly with a clear (secret-free) message.
|
|
6
|
+
*/
|
|
7
|
+
export declare const ConfigSchema: z.ZodObject<{
|
|
8
|
+
telegram: z.ZodObject<{
|
|
9
|
+
botToken: z.ZodString;
|
|
10
|
+
chatId: z.ZodString;
|
|
11
|
+
}, "strip", z.ZodTypeAny, {
|
|
12
|
+
botToken: string;
|
|
13
|
+
chatId: string;
|
|
14
|
+
}, {
|
|
15
|
+
botToken: string;
|
|
16
|
+
chatId: string;
|
|
17
|
+
}>;
|
|
18
|
+
}, "strip", z.ZodTypeAny, {
|
|
19
|
+
telegram: {
|
|
20
|
+
botToken: string;
|
|
21
|
+
chatId: string;
|
|
22
|
+
};
|
|
23
|
+
}, {
|
|
24
|
+
telegram: {
|
|
25
|
+
botToken: string;
|
|
26
|
+
chatId: string;
|
|
27
|
+
};
|
|
28
|
+
}>;
|
|
29
|
+
export type PingConfig = z.infer<typeof ConfigSchema>;
|
|
30
|
+
/** Environment variable name pointing at an explicit config file path. */
|
|
31
|
+
export declare const CONFIG_PATH_ENV = "PING_A_HUMAN_CONFIG";
|
|
32
|
+
/**
|
|
33
|
+
* Default on-disk config location: ~/.config/ping-a-human/config.json.
|
|
34
|
+
* Exported so the S04 setup wizard writes to the same place loadConfig reads.
|
|
35
|
+
*/
|
|
36
|
+
export declare function defaultConfigPath(): string;
|
|
37
|
+
export type LoadConfigOptions = {
|
|
38
|
+
/** Explicit config object — highest precedence (used by tests). */
|
|
39
|
+
config?: PingConfig;
|
|
40
|
+
/** Explicit config file path — overrides env and default. */
|
|
41
|
+
path?: string;
|
|
42
|
+
/** Environment source (defaults to process.env); injectable for tests. */
|
|
43
|
+
env?: NodeJS.ProcessEnv;
|
|
44
|
+
};
|
|
45
|
+
/**
|
|
46
|
+
* Resolve configuration with this precedence:
|
|
47
|
+
* 1. opts.config (explicit object)
|
|
48
|
+
* 2. env vars TELEGRAM_BOT_TOKEN + TELEGRAM_CHAT_ID (both must be present)
|
|
49
|
+
* 3. config file at opts.path -> $PING_A_HUMAN_CONFIG -> defaultConfigPath()
|
|
50
|
+
*
|
|
51
|
+
* Throws a clear, secret-free Error when configuration is missing or invalid.
|
|
52
|
+
* The bot token value is never included in any thrown message.
|
|
53
|
+
*/
|
|
54
|
+
export declare function loadConfig(opts?: LoadConfigOptions): PingConfig;
|
package/dist/config.js
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { z } from "zod";
|
|
5
|
+
/**
|
|
6
|
+
* Persisted configuration shape. The setup wizard (S04) writes this exact
|
|
7
|
+
* structure, and the channel layer reads it. Validated with zod so malformed
|
|
8
|
+
* config fails loudly with a clear (secret-free) message.
|
|
9
|
+
*/
|
|
10
|
+
export const ConfigSchema = z.object({
|
|
11
|
+
telegram: z.object({
|
|
12
|
+
botToken: z.string().min(1),
|
|
13
|
+
// chat ids can be large negative numbers for groups; keep as string to
|
|
14
|
+
// avoid JS number precision issues.
|
|
15
|
+
chatId: z.string().min(1),
|
|
16
|
+
}),
|
|
17
|
+
});
|
|
18
|
+
/** Environment variable name pointing at an explicit config file path. */
|
|
19
|
+
export const CONFIG_PATH_ENV = "PING_A_HUMAN_CONFIG";
|
|
20
|
+
/**
|
|
21
|
+
* Default on-disk config location: ~/.config/ping-a-human/config.json.
|
|
22
|
+
* Exported so the S04 setup wizard writes to the same place loadConfig reads.
|
|
23
|
+
*/
|
|
24
|
+
export function defaultConfigPath() {
|
|
25
|
+
return join(homedir(), ".config", "ping-a-human", "config.json");
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Resolve configuration with this precedence:
|
|
29
|
+
* 1. opts.config (explicit object)
|
|
30
|
+
* 2. env vars TELEGRAM_BOT_TOKEN + TELEGRAM_CHAT_ID (both must be present)
|
|
31
|
+
* 3. config file at opts.path -> $PING_A_HUMAN_CONFIG -> defaultConfigPath()
|
|
32
|
+
*
|
|
33
|
+
* Throws a clear, secret-free Error when configuration is missing or invalid.
|
|
34
|
+
* The bot token value is never included in any thrown message.
|
|
35
|
+
*/
|
|
36
|
+
export function loadConfig(opts = {}) {
|
|
37
|
+
const env = opts.env ?? process.env;
|
|
38
|
+
// 1. Explicit object.
|
|
39
|
+
if (opts.config) {
|
|
40
|
+
return ConfigSchema.parse(opts.config);
|
|
41
|
+
}
|
|
42
|
+
// 2. Environment variable overrides (handy for tests and CI).
|
|
43
|
+
const envToken = env.TELEGRAM_BOT_TOKEN;
|
|
44
|
+
const envChatId = env.TELEGRAM_CHAT_ID;
|
|
45
|
+
if (envToken && envChatId) {
|
|
46
|
+
return ConfigSchema.parse({
|
|
47
|
+
telegram: { botToken: envToken, chatId: envChatId },
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
// 3. Config file.
|
|
51
|
+
const filePath = opts.path ?? env[CONFIG_PATH_ENV] ?? defaultConfigPath();
|
|
52
|
+
let raw;
|
|
53
|
+
try {
|
|
54
|
+
raw = readFileSync(filePath, "utf8");
|
|
55
|
+
}
|
|
56
|
+
catch {
|
|
57
|
+
throw new Error(`No ping-a-human configuration found. Set TELEGRAM_BOT_TOKEN and ` +
|
|
58
|
+
`TELEGRAM_CHAT_ID, or create a config file at ${filePath} ` +
|
|
59
|
+
`(run 'ping-a-human setup').`);
|
|
60
|
+
}
|
|
61
|
+
let parsed;
|
|
62
|
+
try {
|
|
63
|
+
parsed = JSON.parse(raw);
|
|
64
|
+
}
|
|
65
|
+
catch {
|
|
66
|
+
throw new Error(`Config file at ${filePath} is not valid JSON.`);
|
|
67
|
+
}
|
|
68
|
+
const result = ConfigSchema.safeParse(parsed);
|
|
69
|
+
if (!result.success) {
|
|
70
|
+
// Surface which fields are wrong without echoing any values.
|
|
71
|
+
const issues = result.error.issues
|
|
72
|
+
.map((i) => i.path.join("."))
|
|
73
|
+
.filter(Boolean)
|
|
74
|
+
.join(", ");
|
|
75
|
+
throw new Error(`Config file at ${filePath} is missing or has invalid fields` +
|
|
76
|
+
(issues ? `: ${issues}` : "") +
|
|
77
|
+
`. Expected { telegram: { botToken, chatId } }.`);
|
|
78
|
+
}
|
|
79
|
+
return result.data;
|
|
80
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
3
|
+
import { type CreateChannelOptions } from "./channel-factory.js";
|
|
4
|
+
/**
|
|
5
|
+
* Build the MCP server with notify_human and ask_human registered.
|
|
6
|
+
*
|
|
7
|
+
* The Channel is resolved lazily (per tool call) via {@link createChannel} so
|
|
8
|
+
* the server still boots without configuration; a missing/invalid config
|
|
9
|
+
* surfaces as a tool error result instead of crashing at startup. Tests inject
|
|
10
|
+
* a stub Channel through `channelOptions.channel`.
|
|
11
|
+
*/
|
|
12
|
+
export declare function createServer(channelOptions?: CreateChannelOptions): McpServer;
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
3
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
4
|
+
import { realpathSync } from "node:fs";
|
|
5
|
+
import { fileURLToPath } from "node:url";
|
|
6
|
+
import { z } from "zod";
|
|
7
|
+
import { createChannel } from "./channel-factory.js";
|
|
8
|
+
import { askHuman, notifyHuman } from "./tools.js";
|
|
9
|
+
import { runSetup } from "./setup.js";
|
|
10
|
+
/**
|
|
11
|
+
* Build the MCP server with notify_human and ask_human registered.
|
|
12
|
+
*
|
|
13
|
+
* The Channel is resolved lazily (per tool call) via {@link createChannel} so
|
|
14
|
+
* the server still boots without configuration; a missing/invalid config
|
|
15
|
+
* surfaces as a tool error result instead of crashing at startup. Tests inject
|
|
16
|
+
* a stub Channel through `channelOptions.channel`.
|
|
17
|
+
*/
|
|
18
|
+
export function createServer(channelOptions = {}) {
|
|
19
|
+
const server = new McpServer({ name: "ping-a-human", version: "0.1.0" });
|
|
20
|
+
const resolveChannel = () => createChannel(channelOptions);
|
|
21
|
+
// 1.x registerTool: inputSchema is a ZodRawShape (plain object), NOT z.object(...).
|
|
22
|
+
server.registerTool("notify_human", {
|
|
23
|
+
title: "Notify human",
|
|
24
|
+
description: "Send a fire-and-forget message to the configured human (via Telegram) and return immediately without waiting for a reply.",
|
|
25
|
+
inputSchema: { message: z.string() },
|
|
26
|
+
}, async ({ message }) => notifyHuman(resolveChannel(), { message }));
|
|
27
|
+
server.registerTool("ask_human", {
|
|
28
|
+
title: "Ask human",
|
|
29
|
+
description: "Send a question to the configured human and block until they reply (or a timeout elapses). Optionally provide choices to render tappable buttons. Returns the human's answer, or a clear timed-out result.",
|
|
30
|
+
inputSchema: {
|
|
31
|
+
question: z.string(),
|
|
32
|
+
choices: z.array(z.string()).optional(),
|
|
33
|
+
timeoutMs: z.number().int().positive().optional(),
|
|
34
|
+
},
|
|
35
|
+
}, async ({ question, choices, timeoutMs }) => askHuman(resolveChannel(), { question, choices, timeoutMs }));
|
|
36
|
+
return server;
|
|
37
|
+
}
|
|
38
|
+
async function main() {
|
|
39
|
+
// Subcommand routing: `ping-a-human setup` runs the interactive wizard;
|
|
40
|
+
// any other invocation starts the MCP server over stdio.
|
|
41
|
+
if (process.argv[2] === "setup") {
|
|
42
|
+
const code = await runSetup();
|
|
43
|
+
process.exit(code);
|
|
44
|
+
}
|
|
45
|
+
const server = createServer();
|
|
46
|
+
const transport = new StdioServerTransport();
|
|
47
|
+
await server.connect(transport);
|
|
48
|
+
// Diagnostics MUST go to stderr — stdout is the MCP JSON-RPC channel.
|
|
49
|
+
console.error("ping-a-human MCP server running on stdio");
|
|
50
|
+
}
|
|
51
|
+
// Only auto-start when run as the entrypoint, so tests can import createServer.
|
|
52
|
+
// Resolve symlinks on both sides: when launched via the npm-installed bin,
|
|
53
|
+
// process.argv[1] is the .bin symlink while import.meta.url is the real file,
|
|
54
|
+
// so a naive string compare would never match and the server wouldn't start.
|
|
55
|
+
function isEntrypoint() {
|
|
56
|
+
const argv1 = process.argv[1];
|
|
57
|
+
if (!argv1)
|
|
58
|
+
return false;
|
|
59
|
+
try {
|
|
60
|
+
return realpathSync(argv1) === realpathSync(fileURLToPath(import.meta.url));
|
|
61
|
+
}
|
|
62
|
+
catch {
|
|
63
|
+
return false;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
if (isEntrypoint()) {
|
|
67
|
+
main().catch((err) => {
|
|
68
|
+
console.error(err);
|
|
69
|
+
process.exit(1);
|
|
70
|
+
});
|
|
71
|
+
}
|
package/dist/setup.d.ts
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import type { Readable, Writable } from "node:stream";
|
|
2
|
+
import { type PingConfig } from "./config.js";
|
|
3
|
+
type FetchImpl = typeof fetch;
|
|
4
|
+
export type ValidateTokenResult = {
|
|
5
|
+
ok: true;
|
|
6
|
+
botUsername: string;
|
|
7
|
+
} | {
|
|
8
|
+
ok: false;
|
|
9
|
+
reason: string;
|
|
10
|
+
};
|
|
11
|
+
/**
|
|
12
|
+
* Validate a bot token by calling getMe. On success returns the bot's
|
|
13
|
+
* @username (proof the token works) — never the token itself. On failure
|
|
14
|
+
* returns a secret-free reason.
|
|
15
|
+
*/
|
|
16
|
+
export declare function validateToken(token: string, fetchImpl?: FetchImpl): Promise<ValidateTokenResult>;
|
|
17
|
+
export type DetectChatIdResult = {
|
|
18
|
+
chatId: string;
|
|
19
|
+
fromName?: string;
|
|
20
|
+
} | null;
|
|
21
|
+
/**
|
|
22
|
+
* Detect the chat id by calling getUpdates and reading the most recent update
|
|
23
|
+
* that carries a chat. Returns null when there are no usable updates yet (the
|
|
24
|
+
* user must message the bot first). Reads newest-first so a fresh message wins.
|
|
25
|
+
*/
|
|
26
|
+
export declare function detectChatId(token: string, fetchImpl?: FetchImpl): Promise<DetectChatIdResult>;
|
|
27
|
+
export type WriteConfigDeps = {
|
|
28
|
+
path?: string;
|
|
29
|
+
writeFile?: (path: string, data: string) => Promise<void>;
|
|
30
|
+
mkdir?: (path: string, opts: {
|
|
31
|
+
recursive: boolean;
|
|
32
|
+
}) => Promise<unknown>;
|
|
33
|
+
};
|
|
34
|
+
/**
|
|
35
|
+
* Persist config in the exact shape loadConfig reads, creating the parent dir
|
|
36
|
+
* if needed. Returns the path written. Validates against ConfigSchema first so
|
|
37
|
+
* we never write a malformed file.
|
|
38
|
+
*/
|
|
39
|
+
export declare function writeConfig(config: PingConfig, deps?: WriteConfigDeps): Promise<string>;
|
|
40
|
+
/** The MCP client entry users paste into Claude Desktop / Cursor config. */
|
|
41
|
+
export declare function mcpClientEntry(): {
|
|
42
|
+
command: string;
|
|
43
|
+
args: string[];
|
|
44
|
+
};
|
|
45
|
+
/** A ready-to-paste mcpServers JSON snippet for the MCP client config. */
|
|
46
|
+
export declare function mcpClientEntrySnippet(): string;
|
|
47
|
+
export type RunSetupDeps = {
|
|
48
|
+
/** Where prompts are read from (defaults to process.stdin). */
|
|
49
|
+
input?: Readable;
|
|
50
|
+
/** Where prompts/diagnostics are written (defaults to process.stderr). */
|
|
51
|
+
output?: Writable;
|
|
52
|
+
fetchImpl?: FetchImpl;
|
|
53
|
+
writeConfigDeps?: WriteConfigDeps;
|
|
54
|
+
/** Attempts to detect the chat id after the user messages the bot. */
|
|
55
|
+
detectAttempts?: number;
|
|
56
|
+
/** Delay (ms) between chat-id detection attempts. */
|
|
57
|
+
detectDelayMs?: number;
|
|
58
|
+
/** Sleep impl (injectable for tests). */
|
|
59
|
+
sleep?: (ms: number) => Promise<void>;
|
|
60
|
+
};
|
|
61
|
+
/**
|
|
62
|
+
* Interactive setup wizard. Returns a process exit code (0 success, non-zero
|
|
63
|
+
* failure). All prompts/diagnostics go to `output` (stderr by default); the bot
|
|
64
|
+
* token is never written back to output.
|
|
65
|
+
*/
|
|
66
|
+
export declare function runSetup(deps?: RunSetupDeps): Promise<number>;
|
|
67
|
+
export {};
|
package/dist/setup.js
ADDED
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
import { mkdir as fsMkdir, writeFile as fsWriteFile } from "node:fs/promises";
|
|
2
|
+
import { dirname } from "node:path";
|
|
3
|
+
import * as readline from "node:readline/promises";
|
|
4
|
+
import { stdin as processStdin } from "node:process";
|
|
5
|
+
import { ConfigSchema, defaultConfigPath, } from "./config.js";
|
|
6
|
+
const API_BASE = "https://api.telegram.org";
|
|
7
|
+
/** POST a JSON body to a Bot API method. Never includes the token in errors. */
|
|
8
|
+
async function callApi(token, method, body, fetchImpl) {
|
|
9
|
+
const res = await fetchImpl(`${API_BASE}/bot${token}/${method}`, {
|
|
10
|
+
method: "POST",
|
|
11
|
+
headers: { "content-type": "application/json" },
|
|
12
|
+
body: JSON.stringify(body ?? {}),
|
|
13
|
+
});
|
|
14
|
+
return (await res.json());
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* Validate a bot token by calling getMe. On success returns the bot's
|
|
18
|
+
* @username (proof the token works) — never the token itself. On failure
|
|
19
|
+
* returns a secret-free reason.
|
|
20
|
+
*/
|
|
21
|
+
export async function validateToken(token, fetchImpl = fetch) {
|
|
22
|
+
let resp;
|
|
23
|
+
try {
|
|
24
|
+
resp = await callApi(token, "getMe", {}, fetchImpl);
|
|
25
|
+
}
|
|
26
|
+
catch (err) {
|
|
27
|
+
return {
|
|
28
|
+
ok: false,
|
|
29
|
+
reason: `Could not reach Telegram: ${err instanceof Error ? err.message : String(err)}`,
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
if (!resp.ok || !resp.result) {
|
|
33
|
+
return {
|
|
34
|
+
ok: false,
|
|
35
|
+
reason: resp.description ??
|
|
36
|
+
"Telegram rejected the token (getMe failed). Double-check the token from BotFather.",
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
return { ok: true, botUsername: resp.result.username ?? "(unknown)" };
|
|
40
|
+
}
|
|
41
|
+
/** Pull the chat id + best-effort name out of a single update. */
|
|
42
|
+
function chatFromUpdate(u) {
|
|
43
|
+
const msg = u.message ?? u.edited_message ?? u.channel_post;
|
|
44
|
+
if (msg?.chat) {
|
|
45
|
+
return { chatId: String(msg.chat.id), fromName: nameOf(msg) };
|
|
46
|
+
}
|
|
47
|
+
const cbMsg = u.callback_query?.message;
|
|
48
|
+
if (cbMsg?.chat) {
|
|
49
|
+
return {
|
|
50
|
+
chatId: String(cbMsg.chat.id),
|
|
51
|
+
fromName: u.callback_query?.from?.first_name ?? u.callback_query?.from?.username,
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
return null;
|
|
55
|
+
}
|
|
56
|
+
function nameOf(msg) {
|
|
57
|
+
return (msg.from?.first_name ??
|
|
58
|
+
msg.from?.username ??
|
|
59
|
+
msg.chat?.title ??
|
|
60
|
+
msg.chat?.username);
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Detect the chat id by calling getUpdates and reading the most recent update
|
|
64
|
+
* that carries a chat. Returns null when there are no usable updates yet (the
|
|
65
|
+
* user must message the bot first). Reads newest-first so a fresh message wins.
|
|
66
|
+
*/
|
|
67
|
+
export async function detectChatId(token, fetchImpl = fetch) {
|
|
68
|
+
let resp;
|
|
69
|
+
try {
|
|
70
|
+
resp = await callApi(token, "getUpdates", { timeout: 0 }, fetchImpl);
|
|
71
|
+
}
|
|
72
|
+
catch {
|
|
73
|
+
return null;
|
|
74
|
+
}
|
|
75
|
+
const updates = resp.ok && resp.result ? resp.result : [];
|
|
76
|
+
for (let i = updates.length - 1; i >= 0; i--) {
|
|
77
|
+
const found = chatFromUpdate(updates[i]);
|
|
78
|
+
if (found)
|
|
79
|
+
return found;
|
|
80
|
+
}
|
|
81
|
+
return null;
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* Persist config in the exact shape loadConfig reads, creating the parent dir
|
|
85
|
+
* if needed. Returns the path written. Validates against ConfigSchema first so
|
|
86
|
+
* we never write a malformed file.
|
|
87
|
+
*/
|
|
88
|
+
export async function writeConfig(config, deps = {}) {
|
|
89
|
+
const valid = ConfigSchema.parse(config);
|
|
90
|
+
const path = deps.path ?? defaultConfigPath();
|
|
91
|
+
const mkdir = deps.mkdir ?? ((p, o) => fsMkdir(p, o));
|
|
92
|
+
const writeFile = deps.writeFile ?? ((p, d) => fsWriteFile(p, d, "utf8"));
|
|
93
|
+
await mkdir(dirname(path), { recursive: true });
|
|
94
|
+
await writeFile(path, JSON.stringify(valid, null, 2) + "\n");
|
|
95
|
+
return path;
|
|
96
|
+
}
|
|
97
|
+
/** The MCP client entry users paste into Claude Desktop / Cursor config. */
|
|
98
|
+
export function mcpClientEntry() {
|
|
99
|
+
return { command: "npx", args: ["-y", "ping-a-human"] };
|
|
100
|
+
}
|
|
101
|
+
/** A ready-to-paste mcpServers JSON snippet for the MCP client config. */
|
|
102
|
+
export function mcpClientEntrySnippet() {
|
|
103
|
+
return JSON.stringify({ mcpServers: { "ping-a-human": mcpClientEntry() } }, null, 2);
|
|
104
|
+
}
|
|
105
|
+
const defaultSleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
106
|
+
/**
|
|
107
|
+
* Interactive setup wizard. Returns a process exit code (0 success, non-zero
|
|
108
|
+
* failure). All prompts/diagnostics go to `output` (stderr by default); the bot
|
|
109
|
+
* token is never written back to output.
|
|
110
|
+
*/
|
|
111
|
+
export async function runSetup(deps = {}) {
|
|
112
|
+
const input = deps.input ?? processStdin;
|
|
113
|
+
// Default prompts to stderr so this stays consistent with the stdout-is-MCP
|
|
114
|
+
// rule, even though setup is a separate invocation.
|
|
115
|
+
const output = deps.output ?? process.stderr;
|
|
116
|
+
const fetchImpl = deps.fetchImpl ?? fetch;
|
|
117
|
+
const detectAttempts = deps.detectAttempts ?? 10;
|
|
118
|
+
const detectDelayMs = deps.detectDelayMs ?? 2000;
|
|
119
|
+
const sleep = deps.sleep ?? defaultSleep;
|
|
120
|
+
const rl = readline.createInterface({ input, output });
|
|
121
|
+
const log = (s) => output.write(s + "\n");
|
|
122
|
+
// Prompt wrapper that tolerates a closed/EOF'd input stream (e.g. piped or
|
|
123
|
+
// non-interactive runs) by returning empty instead of throwing
|
|
124
|
+
// ERR_USE_AFTER_CLOSE, so the loops fall through to their abort messages.
|
|
125
|
+
const ask = async (q) => {
|
|
126
|
+
try {
|
|
127
|
+
return await rl.question(q);
|
|
128
|
+
}
|
|
129
|
+
catch (err) {
|
|
130
|
+
if (err?.code === "ERR_USE_AFTER_CLOSE")
|
|
131
|
+
return "";
|
|
132
|
+
throw err;
|
|
133
|
+
}
|
|
134
|
+
};
|
|
135
|
+
try {
|
|
136
|
+
log("ping-a-human setup");
|
|
137
|
+
log("");
|
|
138
|
+
log("1. In Telegram, message @BotFather, send /newbot, and follow the prompts.");
|
|
139
|
+
log("2. Copy the bot token it gives you (looks like 123456:ABC-...).");
|
|
140
|
+
log("");
|
|
141
|
+
let token = "";
|
|
142
|
+
let botUsername = "";
|
|
143
|
+
for (let i = 0; i < 3; i++) {
|
|
144
|
+
token = (await ask("Paste your bot token: ")).trim();
|
|
145
|
+
if (!token) {
|
|
146
|
+
log("No token entered.");
|
|
147
|
+
continue;
|
|
148
|
+
}
|
|
149
|
+
const v = await validateToken(token, fetchImpl);
|
|
150
|
+
if (v.ok) {
|
|
151
|
+
botUsername = v.botUsername;
|
|
152
|
+
break;
|
|
153
|
+
}
|
|
154
|
+
log(`Token rejected: ${v.reason}`);
|
|
155
|
+
token = "";
|
|
156
|
+
}
|
|
157
|
+
if (!token) {
|
|
158
|
+
log("Could not validate a bot token after 3 attempts. Aborting.");
|
|
159
|
+
return 1;
|
|
160
|
+
}
|
|
161
|
+
log(`Token OK — bot is @${botUsername}.`);
|
|
162
|
+
log("");
|
|
163
|
+
log(`3. Open Telegram, find @${botUsername}, and send it any message (e.g. \"hi\").`);
|
|
164
|
+
await ask(" Press Enter once you've sent a message to the bot... ");
|
|
165
|
+
let detected = null;
|
|
166
|
+
for (let attempt = 1; attempt <= detectAttempts; attempt++) {
|
|
167
|
+
detected = await detectChatId(token, fetchImpl);
|
|
168
|
+
if (detected)
|
|
169
|
+
break;
|
|
170
|
+
if (attempt < detectAttempts) {
|
|
171
|
+
log(` No message seen yet (attempt ${attempt}/${detectAttempts}); retrying...`);
|
|
172
|
+
await sleep(detectDelayMs);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
if (!detected) {
|
|
176
|
+
log("");
|
|
177
|
+
log("Could not detect your chat. Make sure you sent a message to the bot,");
|
|
178
|
+
log("then run 'ping-a-human setup' again.");
|
|
179
|
+
return 1;
|
|
180
|
+
}
|
|
181
|
+
log(`Detected chat${detected.fromName ? ` from ${detected.fromName}` : ""} (id ${detected.chatId}).`);
|
|
182
|
+
const config = {
|
|
183
|
+
telegram: { botToken: token, chatId: detected.chatId },
|
|
184
|
+
};
|
|
185
|
+
const savedPath = await writeConfig(config, deps.writeConfigDeps);
|
|
186
|
+
log(`Saved config to ${savedPath}`);
|
|
187
|
+
log("");
|
|
188
|
+
log("4. Add this to your MCP client config (Claude Desktop / Cursor):");
|
|
189
|
+
log("");
|
|
190
|
+
log(mcpClientEntrySnippet());
|
|
191
|
+
log("");
|
|
192
|
+
log("Done. notify_human and ask_human are ready to use.");
|
|
193
|
+
return 0;
|
|
194
|
+
}
|
|
195
|
+
finally {
|
|
196
|
+
rl.close();
|
|
197
|
+
}
|
|
198
|
+
}
|
package/dist/tools.d.ts
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import type { Channel } from "./channel.js";
|
|
2
|
+
/**
|
|
3
|
+
* Shape of an MCP tool result we return. Mirrors the structure the MCP SDK
|
|
4
|
+
* expects from a registerTool handler ({ content: [...] }), kept local so these
|
|
5
|
+
* handlers are pure and unit-testable without the SDK.
|
|
6
|
+
*/
|
|
7
|
+
export type ToolResult = {
|
|
8
|
+
content: Array<{
|
|
9
|
+
type: "text";
|
|
10
|
+
text: string;
|
|
11
|
+
}>;
|
|
12
|
+
isError?: boolean;
|
|
13
|
+
};
|
|
14
|
+
/**
|
|
15
|
+
* notify_human: fire-and-forget. Deliver a message to the human channel and
|
|
16
|
+
* return immediately without awaiting any reply (R001).
|
|
17
|
+
*/
|
|
18
|
+
export declare function notifyHuman(channel: Channel, args: {
|
|
19
|
+
message: string;
|
|
20
|
+
}): Promise<ToolResult>;
|
|
21
|
+
export type AskHumanArgs = {
|
|
22
|
+
question: string;
|
|
23
|
+
choices?: string[];
|
|
24
|
+
/** Reply deadline in ms. Defaults to 5 minutes. */
|
|
25
|
+
timeoutMs?: number;
|
|
26
|
+
};
|
|
27
|
+
/**
|
|
28
|
+
* ask_human: deliver a question (optionally with tappable choices), block until
|
|
29
|
+
* the human replies or the deadline elapses, then return the answer + who
|
|
30
|
+
* answered (R002), or a clear timed-out result (R002). Choices render as inline
|
|
31
|
+
* buttons and the tapped value is returned (R003).
|
|
32
|
+
*/
|
|
33
|
+
export declare function askHuman(channel: Channel, args: AskHumanArgs): Promise<ToolResult>;
|
package/dist/tools.js
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
function text(t, isError = false) {
|
|
2
|
+
return { content: [{ type: "text", text: t }], ...(isError ? { isError } : {}) };
|
|
3
|
+
}
|
|
4
|
+
/**
|
|
5
|
+
* notify_human: fire-and-forget. Deliver a message to the human channel and
|
|
6
|
+
* return immediately without awaiting any reply (R001).
|
|
7
|
+
*/
|
|
8
|
+
export async function notifyHuman(channel, args) {
|
|
9
|
+
try {
|
|
10
|
+
const ref = await channel.send(args.message);
|
|
11
|
+
return text(`Message delivered to human (ref: ${ref.id}).`);
|
|
12
|
+
}
|
|
13
|
+
catch (err) {
|
|
14
|
+
return text(`Failed to notify human: ${errMessage(err)}`, true);
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
const DEFAULT_ASK_TIMEOUT_MS = 5 * 60 * 1000;
|
|
18
|
+
/**
|
|
19
|
+
* ask_human: deliver a question (optionally with tappable choices), block until
|
|
20
|
+
* the human replies or the deadline elapses, then return the answer + who
|
|
21
|
+
* answered (R002), or a clear timed-out result (R002). Choices render as inline
|
|
22
|
+
* buttons and the tapped value is returned (R003).
|
|
23
|
+
*/
|
|
24
|
+
export async function askHuman(channel, args) {
|
|
25
|
+
const timeoutMs = args.timeoutMs ?? DEFAULT_ASK_TIMEOUT_MS;
|
|
26
|
+
let ref;
|
|
27
|
+
try {
|
|
28
|
+
ref = await channel.send(args.question, { choices: args.choices });
|
|
29
|
+
}
|
|
30
|
+
catch (err) {
|
|
31
|
+
return text(`Failed to ask human: ${errMessage(err)}`, true);
|
|
32
|
+
}
|
|
33
|
+
let reply;
|
|
34
|
+
try {
|
|
35
|
+
reply = await channel.awaitReply({ timeoutMs, sinceRef: ref });
|
|
36
|
+
}
|
|
37
|
+
catch (err) {
|
|
38
|
+
return text(`Failed while awaiting human reply: ${errMessage(err)}`, true);
|
|
39
|
+
}
|
|
40
|
+
if (reply.status === "timeout") {
|
|
41
|
+
return text(`Timed out after ${timeoutMs}ms waiting for the human to reply. No answer was received.`);
|
|
42
|
+
}
|
|
43
|
+
const who = reply.respondent.name
|
|
44
|
+
? `${reply.respondent.name} (${reply.respondent.id})`
|
|
45
|
+
: reply.respondent.id;
|
|
46
|
+
return text(`Human (${who}) answered: ${reply.answer}`);
|
|
47
|
+
}
|
|
48
|
+
function errMessage(err) {
|
|
49
|
+
return err instanceof Error ? err.message : String(err);
|
|
50
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "ping-a-human",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "An MCP server that adds a human-in-the-loop step to any AI pipeline by reaching a human on their own messaging app (Telegram first).",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"ping-a-human": "dist/index.js"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"dist"
|
|
11
|
+
],
|
|
12
|
+
"engines": {
|
|
13
|
+
"node": ">=18"
|
|
14
|
+
},
|
|
15
|
+
"scripts": {
|
|
16
|
+
"build": "tsc",
|
|
17
|
+
"dev": "tsx src/index.ts",
|
|
18
|
+
"test": "node --test 'tests/*.test.mjs'",
|
|
19
|
+
"prepublishOnly": "npm run build"
|
|
20
|
+
},
|
|
21
|
+
"keywords": [
|
|
22
|
+
"mcp",
|
|
23
|
+
"model-context-protocol",
|
|
24
|
+
"human-in-the-loop",
|
|
25
|
+
"telegram",
|
|
26
|
+
"ai",
|
|
27
|
+
"agent",
|
|
28
|
+
"notification"
|
|
29
|
+
],
|
|
30
|
+
"repository": {
|
|
31
|
+
"type": "git",
|
|
32
|
+
"url": "git+https://github.com/startriseio/ping-a-human.git"
|
|
33
|
+
},
|
|
34
|
+
"homepage": "https://github.com/startriseio/ping-a-human#readme",
|
|
35
|
+
"bugs": {
|
|
36
|
+
"url": "https://github.com/startriseio/ping-a-human/issues"
|
|
37
|
+
},
|
|
38
|
+
"license": "MIT",
|
|
39
|
+
"dependencies": {
|
|
40
|
+
"@modelcontextprotocol/sdk": "^1.29.0",
|
|
41
|
+
"zod": "^3.25.0"
|
|
42
|
+
},
|
|
43
|
+
"devDependencies": {
|
|
44
|
+
"@types/node": "^22.0.0",
|
|
45
|
+
"tsx": "^4.19.0",
|
|
46
|
+
"typescript": "^5.6.0"
|
|
47
|
+
}
|
|
48
|
+
}
|