grok-telegram-bot 2.6.0 → 2.7.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/.env.example +13 -0
- package/CHANGELOG.md +35 -0
- package/README.md +15 -2
- package/docs/INSTALL.md +2 -0
- package/package.json +1 -1
- package/scripts/setup.mjs +20 -3
- package/src/app/instance.ts +223 -0
- package/src/app/types.ts +7 -0
- package/src/bot/ask-user-service.ts +226 -0
- package/src/bot/bot.ts +32 -0
- package/src/bot/commands.ts +22 -2
- package/src/bot/handlers/grok-slash.ts +336 -0
- package/src/bot/handlers/system.ts +63 -1
- package/src/bot/plan-exit-service.ts +169 -0
- package/src/bot/registry.ts +11 -2
- package/src/bot/session-runtime.ts +2 -0
- package/src/cli.ts +43 -7
- package/src/config.ts +35 -25
- package/src/grok/client.ts +29 -5
- package/src/grok/plan-approval.ts +8 -0
- package/src/index.ts +4 -0
- package/src/service/linux.ts +21 -15
- package/src/service/macos.ts +20 -15
- package/src/service/platform.ts +12 -3
- package/src/service/types.ts +6 -0
- package/src/service/windows.ts +31 -22
package/.env.example
CHANGED
|
@@ -40,6 +40,19 @@ GROK_TRUST_ALL_TOOLS=true
|
|
|
40
40
|
# Approve/Deny buttons in Telegram.
|
|
41
41
|
AUTO_APPROVE_PERMISSIONS=true
|
|
42
42
|
|
|
43
|
+
# Auto-approve Grok plan-mode exit (no Approve / Request changes / Abandon).
|
|
44
|
+
# Default true so 24/7 unattended bots never wait. Set false for Telegram review.
|
|
45
|
+
# AUTO_APPROVE_PLAN=true
|
|
46
|
+
|
|
47
|
+
# Optional Grok agent env (applied on process start / after /restart).
|
|
48
|
+
# GROK_SANDBOX=workspace-safe
|
|
49
|
+
# GROK_MEMORY=
|
|
50
|
+
# GROK_AGENT_PROFILE=
|
|
51
|
+
# GROK_PLUGIN_DIR=
|
|
52
|
+
|
|
53
|
+
# Named instance slug (same as grok-tg --name). Optional.
|
|
54
|
+
# GROK_TG_NAME=
|
|
55
|
+
|
|
43
56
|
# Comma-separated roots the /projects browser is allowed to list.
|
|
44
57
|
# Supports ~ for home directory. Defaults to GROK_WORKSPACE's parent + home.
|
|
45
58
|
# Example: H:\Lucru\Domains,C:\Lucru\Domains
|
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,40 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
|
7
7
|
The latest section is published verbatim as the GitHub Release notes by
|
|
8
8
|
`.github/workflows/release.yml` when a `vX.Y.Z` tag is pushed.
|
|
9
9
|
|
|
10
|
+
## [2.7.0] - 2026-08-30
|
|
11
|
+
|
|
12
|
+
Contributor batch from [@vincenthehna](https://github.com/vincenthehna) (PRs
|
|
13
|
+
#6–#8), ported onto current main and credited here. PR #9 (one process / many
|
|
14
|
+
tokens) was **not** merged: it conflicts with forum General + `TOPIC_GROUP_ID`
|
|
15
|
+
(which bot owns the group?) and with the named-instance model.
|
|
16
|
+
|
|
17
|
+
### Added
|
|
18
|
+
|
|
19
|
+
- **Grok Build slash catalog on Telegram** (PR #6 / #7). `/goal`, `/plan`,
|
|
20
|
+
`/compact`, `/workflow(s)`, `/deep_research`, memory/imagine commands, and
|
|
21
|
+
other ACP-useful shell builtins are advertised (menu ≤100) and forwarded into
|
|
22
|
+
the active session (`executeCommand` then prompt fallback). Underscores map
|
|
23
|
+
to Grok hyphens; collision aliases (`/grok_new`, `/memory_flush`, …) keep
|
|
24
|
+
bot-owned bare names. `/goal` is refused in **General** (use a project topic
|
|
25
|
+
or AI Chat). Catch-all forwards unknown Grok/skills slashes.
|
|
26
|
+
- **Named instances** (PR #8). `grok-tg --name work setup|install|status`
|
|
27
|
+
gives each BotFather token its own `~/.grok/tg/instances/<slug>/` and unique
|
|
28
|
+
OS service (`grok-telegram-bot-work`). `grok-tg instances` lists them. Default
|
|
29
|
+
bot and `TELEGRAM_BOT_TOKEN` are unchanged.
|
|
30
|
+
- **Optional interactive plan review** (PR #7). `AUTO_APPROVE_PLAN=false`
|
|
31
|
+
shows Approve / Request changes / Abandon (timeout still unblocks). Default
|
|
32
|
+
remains auto-approve so 24/7 unattended bots never wait. `ask_user_question`
|
|
33
|
+
gets Telegram buttons unless auto-skip is on.
|
|
34
|
+
- **`/sandbox`** plus spawn env: `GROK_SANDBOX`, `GROK_MEMORY`,
|
|
35
|
+
`--agent-profile`, `--plugin-dir`. `session/new` sets `_meta.yoloMode` when
|
|
36
|
+
tools are trusted.
|
|
37
|
+
- **CI workflow** (`typecheck` job) so `main` can require status checks.
|
|
38
|
+
|
|
39
|
+
### Docs
|
|
40
|
+
|
|
41
|
+
- README / INSTALL: several bots on one host via `--name`.
|
|
42
|
+
- AGENTS.md: CLI instances.
|
|
43
|
+
|
|
10
44
|
## [2.6.0] - 2026-08-30
|
|
11
45
|
|
|
12
46
|
General becomes a chat-like **manager** for the forum group: memory-first
|
|
@@ -1001,6 +1035,7 @@ from a single chat and switch between them, on a redesigned, compact menu.
|
|
|
1001
1035
|
diffs, MarkdownV2 rendering, scheduled tasks, multi-image prompts, and a
|
|
1002
1036
|
cross-platform 24/7 background service.
|
|
1003
1037
|
|
|
1038
|
+
[2.7.0]: https://github.com/artickc/grok-telegram-bot/releases/tag/v2.7.0
|
|
1004
1039
|
[2.6.0]: https://github.com/artickc/grok-telegram-bot/releases/tag/v2.6.0
|
|
1005
1040
|
[2.5.0]: https://github.com/artickc/grok-telegram-bot/releases/tag/v2.5.0
|
|
1006
1041
|
[2.4.0]: https://github.com/artickc/grok-telegram-bot/releases/tag/v2.4.0
|
package/README.md
CHANGED
|
@@ -120,8 +120,21 @@ ghost/duplicate that was still polling Telegram (the usual cause of a stale
|
|
|
120
120
|
"⛔ Not authorized"), so the fresh process with your current `.env` wins. A
|
|
121
121
|
plain `grok-tg run` yields to an already-running background service instead.
|
|
122
122
|
|
|
123
|
-
|
|
124
|
-
|
|
123
|
+
Want a **second Telegram bot** (one private chat per project) without switching
|
|
124
|
+
sessions? Create another bot in @BotFather and:
|
|
125
|
+
|
|
126
|
+
```bash
|
|
127
|
+
grok-tg --name work setup <NEW_BOT_TOKEN> <YOUR_USER_ID>
|
|
128
|
+
grok-tg --name work install
|
|
129
|
+
grok-tg instances
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
Each named instance has its own `~/.grok/tg/instances/<slug>/` and unique
|
|
133
|
+
service (`grok-telegram-bot-work`). The default bot is unchanged.
|
|
134
|
+
|
|
135
|
+
Startup options: `grok-tg [--name <slug>] setup [--path] | run | install |
|
|
136
|
+
status | logs [n] | stop | restart | uninstall | instances`. Or try it without
|
|
137
|
+
installing: `npx
|
|
125
138
|
grok-telegram-bot setup`. See **[docs/INSTALL.md](./docs/INSTALL.md)** for the
|
|
126
139
|
full guide.
|
|
127
140
|
|
package/docs/INSTALL.md
CHANGED
|
@@ -56,6 +56,8 @@ grok-tg run # run in the foreground (Ctrl-C to stop)
|
|
|
56
56
|
| `grok-tg logs [n]` | Tail the last `n` log lines (default 100). |
|
|
57
57
|
| `grok-tg stop` / `restart` / `start` | Control the running service. |
|
|
58
58
|
| `grok-tg uninstall` | Stop + remove the background service. |
|
|
59
|
+
| `grok-tg --name <slug> …` | Same commands for a **second Telegram bot** (own token, own chat, own service). |
|
|
60
|
+
| `grok-tg instances` | List the default bot and every named instance. |
|
|
59
61
|
| `grok-tg help` | Show all commands. |
|
|
60
62
|
|
|
61
63
|
The background service is **user-level** and auto-detected per platform — a
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "grok-telegram-bot",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.7.0",
|
|
4
4
|
"description": "Control the official Grok Build CLI from Telegram over the Agent Client Protocol (ACP). Sign in with your xAI account, switch projects, resume sessions, stream responses with diffs, queue follow-ups, manage multiple sign-ins, and run 24/7 as a cross-platform background service.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/index.ts",
|
package/scripts/setup.mjs
CHANGED
|
@@ -3,7 +3,8 @@
|
|
|
3
3
|
* Easy setup: creates/updates the bot's .env, auto-detects the `grok` binary
|
|
4
4
|
* and sensible PROJECT_ROOTS, and optionally writes the bot token / user id:
|
|
5
5
|
*
|
|
6
|
-
* node scripts/setup.mjs [--path] [--instance <dir>] [<
|
|
6
|
+
* node scripts/setup.mjs [--path] [--instance <dir>] [--name <slug>]
|
|
7
|
+
* [<TELEGRAM_BOT_TOKEN> [ALLOWED_USER_ID]]
|
|
7
8
|
*
|
|
8
9
|
* By default the .env lives in the canonical, path-independent home
|
|
9
10
|
* `~/.grok/tg/.env`, so the bot loads the SAME config no matter where it's
|
|
@@ -29,8 +30,23 @@ function expandHome(p) {
|
|
|
29
30
|
function resolveInstanceDir() {
|
|
30
31
|
const flag = process.argv.indexOf("--instance");
|
|
31
32
|
if (flag !== -1 && process.argv[flag + 1]) return resolve(process.argv[flag + 1]);
|
|
32
|
-
const
|
|
33
|
+
const nameEq = process.argv.find((a) => a.startsWith("--name="));
|
|
34
|
+
const nameFlag = process.argv.indexOf("--name");
|
|
35
|
+
const name = nameEq
|
|
36
|
+
? nameEq.slice("--name=".length)
|
|
37
|
+
: nameFlag !== -1
|
|
38
|
+
? process.argv[nameFlag + 1]
|
|
39
|
+
: process.env.GROK_TG_NAME;
|
|
40
|
+
if (name && String(name).trim()) {
|
|
41
|
+
return join(CANONICAL_DIR, "instances", String(name).trim().toLowerCase());
|
|
42
|
+
}
|
|
43
|
+
const envDir = (process.env.GROK_TG_DIR || "").trim();
|
|
33
44
|
if (envDir) return resolve(expandHome(envDir));
|
|
45
|
+
const cwdHint = (process.env.GROK_TG_CWD || "").trim();
|
|
46
|
+
if (cwdHint) {
|
|
47
|
+
const hint = resolve(expandHome(cwdHint));
|
|
48
|
+
if (existsSync(join(hint, ".env"))) return hint;
|
|
49
|
+
}
|
|
34
50
|
if (existsSync(join(process.cwd(), ".env"))) return process.cwd();
|
|
35
51
|
return CANONICAL_DIR;
|
|
36
52
|
}
|
|
@@ -41,7 +57,8 @@ const positionals = [];
|
|
|
41
57
|
for (let i = 0; i < argv.length; i++) {
|
|
42
58
|
const a = argv[i];
|
|
43
59
|
if (a === "--path") pathOnly = true;
|
|
44
|
-
else if (a === "--instance") i++;
|
|
60
|
+
else if (a === "--instance" || a === "--name") i++;
|
|
61
|
+
else if (a.startsWith("--instance=") || a.startsWith("--name=")) continue;
|
|
45
62
|
else positionals.push(a);
|
|
46
63
|
}
|
|
47
64
|
const [tokenArg, userArg] = positionals;
|
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Named bot instances: each Telegram bot token gets its own directory
|
|
3
|
+
* (`.env`, `logs/`, `data/`) and a unique OS service name so several bots
|
|
4
|
+
* can run 24/7 on one host without overwriting each other.
|
|
5
|
+
*
|
|
6
|
+
* The unnamed default stays at `~/.grok/tg` with the original service id.
|
|
7
|
+
* `grok-tg --name work …` uses `~/.grok/tg/instances/work` and
|
|
8
|
+
* `grok-telegram-bot-work`.
|
|
9
|
+
*/
|
|
10
|
+
import { existsSync, readdirSync } from "node:fs";
|
|
11
|
+
import { homedir } from "node:os";
|
|
12
|
+
import { basename, join, resolve } from "node:path";
|
|
13
|
+
|
|
14
|
+
/** Canonical home for the default (unnamed) instance. */
|
|
15
|
+
export const CANONICAL_DIR = join(homedir(), ".grok", "tg");
|
|
16
|
+
export const INSTANCES_SUBDIR = "instances";
|
|
17
|
+
|
|
18
|
+
export const DEFAULT_SERVICE_ID = "grok-telegram-bot";
|
|
19
|
+
export const DEFAULT_WINDOWS_TASK = "GrokTelegramBot";
|
|
20
|
+
export const DEFAULT_MACOS_LABEL = "com.grok.telegrambot";
|
|
21
|
+
|
|
22
|
+
const RESERVED = new Set(["default", "instances", "locks", "logs", "data"]);
|
|
23
|
+
const SLUG_RE = /^[a-z][a-z0-9-]{0,31}$/;
|
|
24
|
+
|
|
25
|
+
export interface InstanceFlags {
|
|
26
|
+
instanceDir?: string;
|
|
27
|
+
name?: string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface ServiceIdentity {
|
|
31
|
+
id: string;
|
|
32
|
+
displayName: string;
|
|
33
|
+
windowsTaskName: string;
|
|
34
|
+
macosLabel: string;
|
|
35
|
+
slug?: string;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface KnownInstance {
|
|
39
|
+
/** `(default)` or the instance slug. */
|
|
40
|
+
name: string;
|
|
41
|
+
dir: string;
|
|
42
|
+
slug?: string;
|
|
43
|
+
identity: ServiceIdentity;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function expandHome(p: string): string {
|
|
47
|
+
if (p === "~") return homedir();
|
|
48
|
+
if (p.startsWith("~/") || p.startsWith("~\\")) return join(homedir(), p.slice(2));
|
|
49
|
+
return p;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function parseInstanceFlags(argv: string[]): InstanceFlags {
|
|
53
|
+
const out: InstanceFlags = {};
|
|
54
|
+
for (let i = 0; i < argv.length; i++) {
|
|
55
|
+
const a = argv[i];
|
|
56
|
+
if (!a) continue;
|
|
57
|
+
if ((a === "--instance" || a === "--name") && argv[i + 1]) {
|
|
58
|
+
const value = argv[++i]!;
|
|
59
|
+
if (a === "--instance") out.instanceDir = value;
|
|
60
|
+
else out.name = value;
|
|
61
|
+
continue;
|
|
62
|
+
}
|
|
63
|
+
if (a.startsWith("--instance=")) out.instanceDir = a.slice("--instance=".length);
|
|
64
|
+
else if (a.startsWith("--name=")) out.name = a.slice("--name=".length);
|
|
65
|
+
}
|
|
66
|
+
return out;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Drop `--instance` / `--name` (and their values) so the CLI command remains first. */
|
|
70
|
+
export function stripInstanceFlags(argv: string[]): string[] {
|
|
71
|
+
const out: string[] = [];
|
|
72
|
+
for (let i = 0; i < argv.length; i++) {
|
|
73
|
+
const a = argv[i];
|
|
74
|
+
if (!a) continue;
|
|
75
|
+
if (a === "--instance" || a === "--name") {
|
|
76
|
+
i++;
|
|
77
|
+
continue;
|
|
78
|
+
}
|
|
79
|
+
if (a.startsWith("--instance=") || a.startsWith("--name=")) continue;
|
|
80
|
+
out.push(a);
|
|
81
|
+
}
|
|
82
|
+
return out;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function parseInstanceSlug(raw: string): string {
|
|
86
|
+
const slug = raw.trim().toLowerCase();
|
|
87
|
+
if (!SLUG_RE.test(slug)) {
|
|
88
|
+
throw new Error(
|
|
89
|
+
`Invalid instance name "${raw}". Use 1–32 characters: start with a letter, then letters, digits or hyphens (e.g. work, home, coding).`,
|
|
90
|
+
);
|
|
91
|
+
}
|
|
92
|
+
if (RESERVED.has(slug)) {
|
|
93
|
+
throw new Error(`Instance name "${slug}" is reserved. Pick another name.`);
|
|
94
|
+
}
|
|
95
|
+
return slug;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export function namedInstanceDir(name: string, canonicalDir = CANONICAL_DIR): string {
|
|
99
|
+
return join(canonicalDir, INSTANCES_SUBDIR, parseInstanceSlug(name));
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Resolve this process's instance directory.
|
|
104
|
+
*
|
|
105
|
+
* Order: `--instance` → `--name` / `GROK_TG_NAME` → `GROK_TG_DIR` → a
|
|
106
|
+
* `GROK_TG_CWD` or cwd that already contains `.env` → `~/.grok/tg`.
|
|
107
|
+
*
|
|
108
|
+
* `GROK_TG_CWD` is only a "user launched from here" hint (the `grok-tg`
|
|
109
|
+
* launcher always sets it). It must not override `--name`, otherwise a
|
|
110
|
+
* named instance could never be selected.
|
|
111
|
+
*/
|
|
112
|
+
export function resolveInstanceDir(opts: {
|
|
113
|
+
argv?: string[];
|
|
114
|
+
envDir?: string;
|
|
115
|
+
nameEnv?: string;
|
|
116
|
+
cwdHint?: string;
|
|
117
|
+
cwd?: string;
|
|
118
|
+
canonicalDir?: string;
|
|
119
|
+
} = {}): string {
|
|
120
|
+
const canonicalDir = opts.canonicalDir ?? CANONICAL_DIR;
|
|
121
|
+
const cwd = opts.cwd ?? process.cwd();
|
|
122
|
+
const flags = parseInstanceFlags(opts.argv ?? process.argv);
|
|
123
|
+
if (flags.instanceDir?.trim()) return resolve(expandHome(flags.instanceDir.trim()));
|
|
124
|
+
const name = flags.name?.trim() || opts.nameEnv?.trim();
|
|
125
|
+
if (name) return namedInstanceDir(name, canonicalDir);
|
|
126
|
+
if (opts.envDir?.trim()) return resolve(expandHome(opts.envDir.trim()));
|
|
127
|
+
const hinted = opts.cwdHint?.trim();
|
|
128
|
+
if (hinted) {
|
|
129
|
+
const hint = resolve(expandHome(hinted));
|
|
130
|
+
if (existsSync(join(hint, ".env"))) return hint;
|
|
131
|
+
}
|
|
132
|
+
if (existsSync(join(cwd, ".env"))) return cwd;
|
|
133
|
+
return canonicalDir;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* OS service names for this instance.
|
|
138
|
+
*
|
|
139
|
+
* Unique names are used only for `--name` / `~/.grok/tg/instances/<slug>` so
|
|
140
|
+
* an existing default install (`grok-telegram-bot.service`) is unchanged.
|
|
141
|
+
* A random folder with a `.env` keeps the default service id.
|
|
142
|
+
*/
|
|
143
|
+
export function serviceIdentity(
|
|
144
|
+
instanceDir: string,
|
|
145
|
+
name?: string,
|
|
146
|
+
canonicalDir = CANONICAL_DIR,
|
|
147
|
+
): ServiceIdentity {
|
|
148
|
+
const slug = name?.trim()
|
|
149
|
+
? parseInstanceSlug(name)
|
|
150
|
+
: inferNamedInstanceSlug(instanceDir, canonicalDir);
|
|
151
|
+
if (!slug) {
|
|
152
|
+
return {
|
|
153
|
+
id: DEFAULT_SERVICE_ID,
|
|
154
|
+
displayName: "Grok Telegram Bot",
|
|
155
|
+
windowsTaskName: DEFAULT_WINDOWS_TASK,
|
|
156
|
+
macosLabel: DEFAULT_MACOS_LABEL,
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
return {
|
|
160
|
+
id: `${DEFAULT_SERVICE_ID}-${slug}`,
|
|
161
|
+
displayName: `Grok Telegram Bot (${slug})`,
|
|
162
|
+
windowsTaskName: `${DEFAULT_WINDOWS_TASK}-${slug}`,
|
|
163
|
+
macosLabel: `${DEFAULT_MACOS_LABEL}.${slug}`,
|
|
164
|
+
slug,
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
export function listKnownInstances(canonicalDir = CANONICAL_DIR): KnownInstance[] {
|
|
169
|
+
const out: KnownInstance[] = [];
|
|
170
|
+
if (existsSync(join(canonicalDir, ".env"))) {
|
|
171
|
+
out.push({
|
|
172
|
+
name: "(default)",
|
|
173
|
+
dir: canonicalDir,
|
|
174
|
+
identity: serviceIdentity(canonicalDir, undefined, canonicalDir),
|
|
175
|
+
});
|
|
176
|
+
}
|
|
177
|
+
const root = join(canonicalDir, INSTANCES_SUBDIR);
|
|
178
|
+
if (!existsSync(root)) return out;
|
|
179
|
+
let names: string[] = [];
|
|
180
|
+
try {
|
|
181
|
+
names = readdirSync(root, { withFileTypes: true })
|
|
182
|
+
.filter((d) => d.isDirectory())
|
|
183
|
+
.map((d) => d.name)
|
|
184
|
+
.sort();
|
|
185
|
+
} catch {
|
|
186
|
+
return out;
|
|
187
|
+
}
|
|
188
|
+
for (const name of names) {
|
|
189
|
+
const dir = join(root, name);
|
|
190
|
+
if (!existsSync(join(dir, ".env"))) continue;
|
|
191
|
+
out.push({
|
|
192
|
+
name,
|
|
193
|
+
dir,
|
|
194
|
+
slug: name,
|
|
195
|
+
identity: serviceIdentity(dir, undefined, canonicalDir),
|
|
196
|
+
});
|
|
197
|
+
}
|
|
198
|
+
return out;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/** Basename when `instanceDir` is a direct child of `<canonical>/instances`. */
|
|
202
|
+
function namedChildName(instanceDir: string, canonicalDir: string): string | undefined {
|
|
203
|
+
const resolved = resolve(instanceDir);
|
|
204
|
+
const parent = resolve(join(resolved, ".."));
|
|
205
|
+
const root = resolve(join(canonicalDir, INSTANCES_SUBDIR));
|
|
206
|
+
if (parent !== root) return undefined;
|
|
207
|
+
return basename(resolved);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function inferNamedInstanceSlug(instanceDir: string, canonicalDir: string): string | undefined {
|
|
211
|
+
const child = namedChildName(instanceDir, canonicalDir);
|
|
212
|
+
if (!child) return undefined;
|
|
213
|
+
try {
|
|
214
|
+
return parseInstanceSlug(child);
|
|
215
|
+
} catch {
|
|
216
|
+
const fallback = child
|
|
217
|
+
.toLowerCase()
|
|
218
|
+
.replace(/[^a-z0-9-]+/g, "-")
|
|
219
|
+
.replace(/^-+|-+$/g, "")
|
|
220
|
+
.slice(0, 32);
|
|
221
|
+
return fallback && SLUG_RE.test(fallback) && !RESERVED.has(fallback) ? fallback : "extra";
|
|
222
|
+
}
|
|
223
|
+
}
|
package/src/app/types.ts
CHANGED
|
@@ -97,6 +97,11 @@ export interface PromptInput {
|
|
|
97
97
|
* "Thinking…" then streams the agent reply into.
|
|
98
98
|
*/
|
|
99
99
|
seedMessageId?: number;
|
|
100
|
+
/**
|
|
101
|
+
* Grok Build slash command (`/goal`, `/compact`, …). Must stay the first
|
|
102
|
+
* line of the prompt — skip manager/complexity wrappers.
|
|
103
|
+
*/
|
|
104
|
+
rawSlashCommand?: boolean;
|
|
100
105
|
}
|
|
101
106
|
|
|
102
107
|
export function textPrompt(
|
|
@@ -108,6 +113,7 @@ export function textPrompt(
|
|
|
108
113
|
promptId?: string;
|
|
109
114
|
reportBack?: PromptInput["reportBack"];
|
|
110
115
|
seedMessageId?: number;
|
|
116
|
+
rawSlashCommand?: boolean;
|
|
111
117
|
},
|
|
112
118
|
): PromptInput {
|
|
113
119
|
return {
|
|
@@ -120,5 +126,6 @@ export function textPrompt(
|
|
|
120
126
|
skipSelfRecheck: opts?.skipSelfRecheck,
|
|
121
127
|
reportBack: opts?.reportBack,
|
|
122
128
|
seedMessageId: opts?.seedMessageId,
|
|
129
|
+
rawSlashCommand: opts?.rawSlashCommand,
|
|
123
130
|
};
|
|
124
131
|
}
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Interactive Grok `ask_user_question` reverse requests.
|
|
3
|
+
* Headless default used to skip; we now present options as Telegram buttons.
|
|
4
|
+
*/
|
|
5
|
+
import type { Api } from "grammy";
|
|
6
|
+
import { InlineKeyboard } from "grammy";
|
|
7
|
+
import { outboundThreadExtra } from "../forum/thread.js";
|
|
8
|
+
import { createLogger } from "../logger.js";
|
|
9
|
+
import type { RuntimeRegistry } from "./registry.js";
|
|
10
|
+
|
|
11
|
+
const log = createLogger("ask-user");
|
|
12
|
+
const TIMEOUT_MS = 30 * 60 * 1000;
|
|
13
|
+
|
|
14
|
+
export interface InterviewQuestion {
|
|
15
|
+
id: string;
|
|
16
|
+
prompt: string;
|
|
17
|
+
options: Array<{ id: string; label: string }>;
|
|
18
|
+
multi: boolean;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** ACP reverse-request result (externally tagged enum, smoke-verified skip). */
|
|
22
|
+
export type AskUserResult =
|
|
23
|
+
| { SkipInterview: null }
|
|
24
|
+
| { SubmitAnswers: { answers: Array<{ questionId: string; selected: string[] }> } };
|
|
25
|
+
|
|
26
|
+
interface Pending {
|
|
27
|
+
resolve: (r: AskUserResult) => void;
|
|
28
|
+
chatId: number;
|
|
29
|
+
messageId?: number;
|
|
30
|
+
questions: InterviewQuestion[];
|
|
31
|
+
picked: Map<string, Set<string>>;
|
|
32
|
+
index: number;
|
|
33
|
+
timer: NodeJS.Timeout;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export class AskUserService {
|
|
37
|
+
private readonly pending = new Map<string, Pending>();
|
|
38
|
+
private seq = 0;
|
|
39
|
+
|
|
40
|
+
constructor(
|
|
41
|
+
private readonly api: Api,
|
|
42
|
+
private readonly registry: RuntimeRegistry,
|
|
43
|
+
public autoSkip = false,
|
|
44
|
+
) {}
|
|
45
|
+
|
|
46
|
+
async handle(params: Record<string, unknown>): Promise<AskUserResult> {
|
|
47
|
+
const questions = parseQuestions(params);
|
|
48
|
+
const sessionId = str(params.sessionId) || str(params.session_id) || "";
|
|
49
|
+
if (this.autoSkip || questions.length === 0) {
|
|
50
|
+
log.info(`skip ask_user_question (${questions.length} q)`);
|
|
51
|
+
return { SkipInterview: null };
|
|
52
|
+
}
|
|
53
|
+
const desc = sessionId ? this.registry.describeSession(sessionId) : { chatId: undefined };
|
|
54
|
+
const chatId = desc.chatId;
|
|
55
|
+
if (chatId === undefined) return { SkipInterview: null };
|
|
56
|
+
const threadExtra = outboundThreadExtra(desc.threadId);
|
|
57
|
+
|
|
58
|
+
const reqId = String(++this.seq);
|
|
59
|
+
const picked = new Map<string, Set<string>>();
|
|
60
|
+
for (const q of questions) picked.set(q.id, new Set());
|
|
61
|
+
|
|
62
|
+
try {
|
|
63
|
+
const msg = await this.api.sendMessage(chatId, renderQuestion(questions, 0, picked), {
|
|
64
|
+
reply_markup: questionKeyboard(reqId, questions[0]!, picked.get(questions[0]!.id)!),
|
|
65
|
+
disable_notification: false,
|
|
66
|
+
...threadExtra,
|
|
67
|
+
});
|
|
68
|
+
return new Promise<AskUserResult>((resolve) => {
|
|
69
|
+
const timer = setTimeout(() => {
|
|
70
|
+
const p = this.pending.get(reqId);
|
|
71
|
+
if (!p) return;
|
|
72
|
+
this.pending.delete(reqId);
|
|
73
|
+
void this.api.editMessageText(p.chatId, p.messageId ?? 0, "\u231B Question timed out \u2014 skipped.").catch(
|
|
74
|
+
() => {},
|
|
75
|
+
);
|
|
76
|
+
resolve({ SkipInterview: null });
|
|
77
|
+
}, TIMEOUT_MS);
|
|
78
|
+
this.pending.set(reqId, {
|
|
79
|
+
resolve,
|
|
80
|
+
chatId,
|
|
81
|
+
messageId: msg.message_id,
|
|
82
|
+
questions,
|
|
83
|
+
picked,
|
|
84
|
+
index: 0,
|
|
85
|
+
timer,
|
|
86
|
+
});
|
|
87
|
+
});
|
|
88
|
+
} catch (e) {
|
|
89
|
+
log.warn("send ask_user failed:", (e as Error).message);
|
|
90
|
+
return { SkipInterview: null };
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
tap(reqId: string, kind: string, value?: string): string | undefined {
|
|
95
|
+
const p = this.pending.get(reqId);
|
|
96
|
+
if (!p) return undefined;
|
|
97
|
+
const q = p.questions[p.index];
|
|
98
|
+
if (!q) return undefined;
|
|
99
|
+
|
|
100
|
+
if (kind === "skip") {
|
|
101
|
+
this.settle(p, reqId, { SkipInterview: null }, "\u23ED Skipped questions.");
|
|
102
|
+
return "Skipped";
|
|
103
|
+
}
|
|
104
|
+
if (kind === "opt" && value !== undefined) {
|
|
105
|
+
const opt = q.options[Number(value)];
|
|
106
|
+
if (!opt) return "Expired";
|
|
107
|
+
const set = p.picked.get(q.id)!;
|
|
108
|
+
if (q.multi) {
|
|
109
|
+
if (set.has(opt.id)) set.delete(opt.id);
|
|
110
|
+
else set.add(opt.id);
|
|
111
|
+
} else {
|
|
112
|
+
set.clear();
|
|
113
|
+
set.add(opt.id);
|
|
114
|
+
}
|
|
115
|
+
void this.redraw(reqId, p);
|
|
116
|
+
return q.multi ? "Toggled" : "Selected";
|
|
117
|
+
}
|
|
118
|
+
if (kind === "next") {
|
|
119
|
+
if (p.index < p.questions.length - 1) {
|
|
120
|
+
p.index += 1;
|
|
121
|
+
void this.redraw(reqId, p);
|
|
122
|
+
return "Next";
|
|
123
|
+
}
|
|
124
|
+
const answers = p.questions.map((qq) => ({
|
|
125
|
+
questionId: qq.id,
|
|
126
|
+
selected: [...(p.picked.get(qq.id) ?? [])],
|
|
127
|
+
}));
|
|
128
|
+
this.settle(p, reqId, { SubmitAnswers: { answers } }, "\u2705 Answers sent.");
|
|
129
|
+
return "Submitted";
|
|
130
|
+
}
|
|
131
|
+
if (kind === "prev" && p.index > 0) {
|
|
132
|
+
p.index -= 1;
|
|
133
|
+
void this.redraw(reqId, p);
|
|
134
|
+
return "Back";
|
|
135
|
+
}
|
|
136
|
+
return undefined;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
private async redraw(reqId: string, p: Pending): Promise<void> {
|
|
140
|
+
const q = p.questions[p.index]!;
|
|
141
|
+
const set = p.picked.get(q.id)!;
|
|
142
|
+
if (p.messageId === undefined) return;
|
|
143
|
+
await this.api
|
|
144
|
+
.editMessageText(p.chatId, p.messageId, renderQuestion(p.questions, p.index, p.picked), {
|
|
145
|
+
reply_markup: questionKeyboard(reqId, q, set, p.index, p.questions.length),
|
|
146
|
+
})
|
|
147
|
+
.catch(() => {});
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
private settle(p: Pending, reqId: string, result: AskUserResult, text: string): void {
|
|
151
|
+
clearTimeout(p.timer);
|
|
152
|
+
this.pending.delete(reqId);
|
|
153
|
+
if (p.messageId !== undefined) {
|
|
154
|
+
void this.api.editMessageText(p.chatId, p.messageId, text, { reply_markup: { inline_keyboard: [] } }).catch(
|
|
155
|
+
() => {},
|
|
156
|
+
);
|
|
157
|
+
}
|
|
158
|
+
p.resolve(result);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
export function parseQuestions(params: Record<string, unknown>): InterviewQuestion[] {
|
|
163
|
+
const raw = params.questions ?? params.questionnaire ?? params.items;
|
|
164
|
+
if (!Array.isArray(raw)) return [];
|
|
165
|
+
const out: InterviewQuestion[] = [];
|
|
166
|
+
raw.forEach((item, i) => {
|
|
167
|
+
if (!item || typeof item !== "object") return;
|
|
168
|
+
const o = item as Record<string, unknown>;
|
|
169
|
+
const prompt = str(o.question) || str(o.prompt) || str(o.header) || str(o.text) || `Question ${i + 1}`;
|
|
170
|
+
const id = str(o.id) || str(o.questionId) || `q${i}`;
|
|
171
|
+
const multi = o.multiSelect === true || o.multi === true || o.allow_multiple === true;
|
|
172
|
+
const optsRaw = Array.isArray(o.options) ? o.options : [];
|
|
173
|
+
const options = optsRaw
|
|
174
|
+
.map((opt, j) => {
|
|
175
|
+
if (typeof opt === "string") return { id: opt, label: opt };
|
|
176
|
+
if (!opt || typeof opt !== "object") return undefined;
|
|
177
|
+
const oo = opt as Record<string, unknown>;
|
|
178
|
+
const label = str(oo.label) || str(oo.name) || str(oo.text) || `Option ${j + 1}`;
|
|
179
|
+
const oid = str(oo.id) || str(oo.value) || label;
|
|
180
|
+
return { id: oid, label };
|
|
181
|
+
})
|
|
182
|
+
.filter((x): x is { id: string; label: string } => Boolean(x));
|
|
183
|
+
if (options.length === 0) options.push({ id: "yes", label: "Yes" }, { id: "no", label: "No" });
|
|
184
|
+
out.push({ id, prompt, options, multi });
|
|
185
|
+
});
|
|
186
|
+
return out;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function renderQuestion(
|
|
190
|
+
questions: InterviewQuestion[],
|
|
191
|
+
index: number,
|
|
192
|
+
picked: Map<string, Set<string>>,
|
|
193
|
+
): string {
|
|
194
|
+
const q = questions[index]!;
|
|
195
|
+
const set = picked.get(q.id) ?? new Set();
|
|
196
|
+
const lines = [
|
|
197
|
+
`\u2753 Grok has a question (${index + 1}/${questions.length})`,
|
|
198
|
+
"",
|
|
199
|
+
q.prompt,
|
|
200
|
+
q.multi ? "\n(multi-select \u2014 tap to toggle, then Next)" : "",
|
|
201
|
+
set.size ? `\nSelected: ${[...set].join(", ")}` : "",
|
|
202
|
+
];
|
|
203
|
+
return lines.filter(Boolean).join("\n");
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function questionKeyboard(
|
|
207
|
+
reqId: string,
|
|
208
|
+
q: InterviewQuestion,
|
|
209
|
+
selected: Set<string>,
|
|
210
|
+
index = 0,
|
|
211
|
+
total = 1,
|
|
212
|
+
): InlineKeyboard {
|
|
213
|
+
const kb = new InlineKeyboard();
|
|
214
|
+
q.options.slice(0, 12).forEach((opt, j) => {
|
|
215
|
+
const mark = selected.has(opt.id) ? "\u2705 " : "";
|
|
216
|
+
kb.text(`${mark}${opt.label.slice(0, 40)}`, `asku:${reqId}:opt:${j}`).row();
|
|
217
|
+
});
|
|
218
|
+
if (index > 0) kb.text("\u25C0 Back", `asku:${reqId}:prev`);
|
|
219
|
+
kb.text(index < total - 1 ? "Next \u25B6" : "\u2705 Submit", `asku:${reqId}:next`);
|
|
220
|
+
kb.row().text("Skip", `asku:${reqId}:skip`);
|
|
221
|
+
return kb;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function str(v: unknown): string {
|
|
225
|
+
return typeof v === "string" ? v : "";
|
|
226
|
+
}
|