grok-telegram-bot 2.5.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 +106 -0
- package/README.md +20 -5
- package/docs/GROUP.md +39 -4
- package/docs/INSTALL.md +2 -0
- package/package.json +4 -4
- package/scripts/setup.mjs +20 -3
- package/src/app/instance.ts +223 -0
- package/src/app/types.ts +34 -1
- package/src/bot/ask-user-service.ts +226 -0
- package/src/bot/auth.ts +5 -1
- package/src/bot/bot.ts +105 -4
- package/src/bot/chat-controller.ts +129 -0
- package/src/bot/commands.ts +22 -2
- package/src/bot/group-memory.ts +192 -12
- package/src/bot/handlers/forum.ts +16 -6
- package/src/bot/handlers/grok-slash.ts +336 -0
- package/src/bot/handlers/message.ts +165 -25
- package/src/bot/handlers/photo.ts +4 -1
- package/src/bot/handlers/system.ts +63 -1
- package/src/bot/image-return.ts +4 -1
- package/src/bot/manager-context.ts +208 -0
- package/src/bot/manager-jobs.ts +142 -0
- package/src/bot/menu/ephemeral.ts +4 -1
- package/src/bot/plan-exit-service.ts +169 -0
- package/src/bot/prompt-anchor.ts +2 -3
- package/src/bot/prompt-content.ts +5 -0
- package/src/bot/registry.ts +11 -2
- package/src/bot/scope.ts +9 -8
- package/src/bot/session-runtime.ts +665 -55
- package/src/bot/telegram-actions.ts +728 -38
- package/src/bot/telegram-bots.ts +2 -1
- package/src/bot/telegram-io.ts +4 -1
- package/src/cli.ts +43 -7
- package/src/config.ts +35 -25
- package/src/forum/manager.ts +2 -1
- package/src/forum/thread.ts +33 -0
- package/src/grok/client.ts +29 -5
- package/src/grok/plan-approval.ts +8 -0
- package/src/index.ts +4 -0
- package/src/render/manager-directive.ts +137 -0
- package/src/render/session-comment.ts +10 -0
- package/src/render/telegram-bridge.ts +118 -14
- 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/src/sessions/history.ts +18 -0
- package/src/stream/streamer.ts +46 -10
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* In-memory tracking of work dispatched from General (manager) into project topics.
|
|
3
|
+
* Used for status, context inject, and report-back when a child turn finishes.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
export type ManagerJobStatus = "dispatched" | "running" | "done" | "failed" | "cancelled";
|
|
7
|
+
|
|
8
|
+
export interface ManagerJob {
|
|
9
|
+
id: string;
|
|
10
|
+
originChatId: number;
|
|
11
|
+
originThreadId: number;
|
|
12
|
+
targetThreadId: number;
|
|
13
|
+
targetName: string;
|
|
14
|
+
targetPath: string;
|
|
15
|
+
childSessionId?: string;
|
|
16
|
+
dispatchPrompt: string;
|
|
17
|
+
userAskPreview: string;
|
|
18
|
+
createdAt: number;
|
|
19
|
+
updatedAt: number;
|
|
20
|
+
status: ManagerJobStatus;
|
|
21
|
+
resultSummary?: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface ReportBackMeta {
|
|
25
|
+
jobId: string;
|
|
26
|
+
originChatId: number;
|
|
27
|
+
originThreadId: number;
|
|
28
|
+
userAskPreview: string;
|
|
29
|
+
targetName: string;
|
|
30
|
+
targetPath: string;
|
|
31
|
+
dispatchPrompt: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const MAX_JOBS = 100;
|
|
35
|
+
|
|
36
|
+
const jobsById = new Map<string, ManagerJob>();
|
|
37
|
+
const jobIdBySession = new Map<string, string>();
|
|
38
|
+
|
|
39
|
+
let seq = 0;
|
|
40
|
+
|
|
41
|
+
export function newManagerJobId(): string {
|
|
42
|
+
seq = (seq + 1) % 1_000_000;
|
|
43
|
+
return `mj_${Date.now().toString(36)}_${seq.toString(36)}`;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function registerManagerJob(input: {
|
|
47
|
+
originChatId: number;
|
|
48
|
+
originThreadId: number;
|
|
49
|
+
targetThreadId: number;
|
|
50
|
+
targetName: string;
|
|
51
|
+
targetPath: string;
|
|
52
|
+
dispatchPrompt: string;
|
|
53
|
+
userAskPreview: string;
|
|
54
|
+
childSessionId?: string;
|
|
55
|
+
}): ManagerJob {
|
|
56
|
+
const now = Date.now();
|
|
57
|
+
const job: ManagerJob = {
|
|
58
|
+
id: newManagerJobId(),
|
|
59
|
+
originChatId: input.originChatId,
|
|
60
|
+
originThreadId: input.originThreadId,
|
|
61
|
+
targetThreadId: input.targetThreadId,
|
|
62
|
+
targetName: input.targetName,
|
|
63
|
+
targetPath: input.targetPath,
|
|
64
|
+
childSessionId: input.childSessionId,
|
|
65
|
+
dispatchPrompt: input.dispatchPrompt,
|
|
66
|
+
userAskPreview: input.userAskPreview,
|
|
67
|
+
createdAt: now,
|
|
68
|
+
updatedAt: now,
|
|
69
|
+
status: "dispatched",
|
|
70
|
+
};
|
|
71
|
+
jobsById.set(job.id, job);
|
|
72
|
+
if (job.childSessionId) jobIdBySession.set(job.childSessionId, job.id);
|
|
73
|
+
trimJobs();
|
|
74
|
+
return job;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function bindJobSession(jobId: string, sessionId: string): void {
|
|
78
|
+
const job = jobsById.get(jobId);
|
|
79
|
+
if (!job) return;
|
|
80
|
+
job.childSessionId = sessionId;
|
|
81
|
+
job.updatedAt = Date.now();
|
|
82
|
+
if (job.status === "dispatched") job.status = "running";
|
|
83
|
+
jobIdBySession.set(sessionId, jobId);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function updateManagerJob(
|
|
87
|
+
jobId: string,
|
|
88
|
+
patch: Partial<Pick<ManagerJob, "status" | "resultSummary" | "childSessionId">>,
|
|
89
|
+
): ManagerJob | undefined {
|
|
90
|
+
const job = jobsById.get(jobId);
|
|
91
|
+
if (!job) return undefined;
|
|
92
|
+
if (patch.status !== undefined) job.status = patch.status;
|
|
93
|
+
if (patch.resultSummary !== undefined) job.resultSummary = patch.resultSummary;
|
|
94
|
+
if (patch.childSessionId !== undefined) {
|
|
95
|
+
job.childSessionId = patch.childSessionId;
|
|
96
|
+
jobIdBySession.set(patch.childSessionId, jobId);
|
|
97
|
+
}
|
|
98
|
+
job.updatedAt = Date.now();
|
|
99
|
+
return job;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export function getManagerJob(jobId: string): ManagerJob | undefined {
|
|
103
|
+
return jobsById.get(jobId);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export function getJobBySession(sessionId: string): ManagerJob | undefined {
|
|
107
|
+
const id = jobIdBySession.get(sessionId);
|
|
108
|
+
return id ? jobsById.get(id) : undefined;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** Active (not terminal) jobs, newest first. */
|
|
112
|
+
export function listActiveManagerJobs(limit = 12): ManagerJob[] {
|
|
113
|
+
return [...jobsById.values()]
|
|
114
|
+
.filter((j) => j.status === "dispatched" || j.status === "running")
|
|
115
|
+
.sort((a, b) => b.updatedAt - a.updatedAt)
|
|
116
|
+
.slice(0, limit);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** Recent jobs including finished, newest first. */
|
|
120
|
+
export function listRecentManagerJobs(limit = 12): ManagerJob[] {
|
|
121
|
+
return [...jobsById.values()]
|
|
122
|
+
.sort((a, b) => b.updatedAt - a.updatedAt)
|
|
123
|
+
.slice(0, Math.max(1, Math.min(50, limit)));
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function trimJobs(): void {
|
|
127
|
+
if (jobsById.size <= MAX_JOBS) return;
|
|
128
|
+
const ordered = [...jobsById.values()].sort((a, b) => a.updatedAt - b.updatedAt);
|
|
129
|
+
const drop = ordered.length - MAX_JOBS;
|
|
130
|
+
for (let i = 0; i < drop; i++) {
|
|
131
|
+
const j = ordered[i]!;
|
|
132
|
+
jobsById.delete(j.id);
|
|
133
|
+
if (j.childSessionId) jobIdBySession.delete(j.childSessionId);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** Test helper: wipe job state. */
|
|
138
|
+
export function clearManagerJobsForTests(): void {
|
|
139
|
+
jobsById.clear();
|
|
140
|
+
jobIdBySession.clear();
|
|
141
|
+
seq = 0;
|
|
142
|
+
}
|
|
@@ -101,8 +101,11 @@ export class Ephemeral {
|
|
|
101
101
|
msg && "message_thread_id" in msg
|
|
102
102
|
? (msg as { message_thread_id?: number }).message_thread_id
|
|
103
103
|
: undefined;
|
|
104
|
+
// Omit General (1) — Bot API rejects message_thread_id=1.
|
|
104
105
|
const merged =
|
|
105
|
-
threadId !== undefined &&
|
|
106
|
+
threadId !== undefined &&
|
|
107
|
+
threadId !== 1 &&
|
|
108
|
+
extra.message_thread_id === undefined
|
|
106
109
|
? { ...extra, message_thread_id: threadId }
|
|
107
110
|
: extra;
|
|
108
111
|
return this.serialize(chatId, async () => {
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Interactive plan-mode approval. Grok's exit_plan_mode reverse request
|
|
3
|
+
* becomes Approve / Request changes / Abandon buttons (same idea as
|
|
4
|
+
* PermissionService). Auto-approve is used when configured, or when the
|
|
5
|
+
* session has no owning chat (scheduled / orphan).
|
|
6
|
+
*/
|
|
7
|
+
import type { Api } from "grammy";
|
|
8
|
+
import { InlineKeyboard } from "grammy";
|
|
9
|
+
import type { PlanExitDecision, PlanExitOutcome } from "../grok/plan-approval.js";
|
|
10
|
+
import { outboundThreadExtra } from "../forum/thread.js";
|
|
11
|
+
import { createLogger } from "../logger.js";
|
|
12
|
+
import type { RuntimeRegistry } from "./registry.js";
|
|
13
|
+
|
|
14
|
+
const log = createLogger("plan-exit");
|
|
15
|
+
const TIMEOUT_MS = 30 * 60 * 1000;
|
|
16
|
+
const PREVIEW = 900;
|
|
17
|
+
|
|
18
|
+
interface Pending {
|
|
19
|
+
resolve: (d: PlanExitDecision) => void;
|
|
20
|
+
chatId: number;
|
|
21
|
+
messageId?: number;
|
|
22
|
+
timer: NodeJS.Timeout;
|
|
23
|
+
pinned: boolean;
|
|
24
|
+
waitingFeedback: boolean;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export class PlanExitService {
|
|
28
|
+
private readonly pending = new Map<string, Pending>();
|
|
29
|
+
/** chatId → reqId while we wait for revision notes after "Request changes". */
|
|
30
|
+
private readonly feedbackFor = new Map<number, string>();
|
|
31
|
+
private seq = 0;
|
|
32
|
+
|
|
33
|
+
constructor(
|
|
34
|
+
private readonly api: Api,
|
|
35
|
+
private readonly registry: RuntimeRegistry,
|
|
36
|
+
public autoApprove = false,
|
|
37
|
+
private readonly onUnpinned?: (chatId: number) => void | Promise<void>,
|
|
38
|
+
) {}
|
|
39
|
+
|
|
40
|
+
async handle(params: Record<string, unknown>): Promise<PlanExitDecision> {
|
|
41
|
+
const sessionId = str(params.sessionId) || str(params.session_id) || "";
|
|
42
|
+
const planText = extractPlanText(params);
|
|
43
|
+
if (this.autoApprove) {
|
|
44
|
+
log.info(`auto-approved plan exit for ${sessionId.slice(0, 8) || "?"}`);
|
|
45
|
+
return { outcome: "approved", feedback: "" };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const desc = sessionId ? this.registry.describeSession(sessionId) : { chatId: undefined };
|
|
49
|
+
const chatId = desc.chatId;
|
|
50
|
+
if (chatId === undefined) return { outcome: "approved", feedback: "" };
|
|
51
|
+
const threadExtra = outboundThreadExtra(desc.threadId);
|
|
52
|
+
|
|
53
|
+
const reqId = String(++this.seq);
|
|
54
|
+
const preview = planText.replace(/\s+/g, " ").trim().slice(0, PREVIEW);
|
|
55
|
+
const body = [
|
|
56
|
+
"\u{1F4CB} Plan ready \u2014 review before Grok implements.",
|
|
57
|
+
preview ? `\n${preview}${planText.length > PREVIEW ? "\u2026" : ""}` : "\n(No plan text in the request.)",
|
|
58
|
+
"\nApprove to build, request changes (then send notes), or abandon.",
|
|
59
|
+
].join("\n");
|
|
60
|
+
|
|
61
|
+
const kb = new InlineKeyboard()
|
|
62
|
+
.text("\u2705 Approve", `planx:${reqId}:ok`)
|
|
63
|
+
.text("\u270F\uFE0F Changes", `planx:${reqId}:chg`)
|
|
64
|
+
.row()
|
|
65
|
+
.text("\u26D4 Abandon", `planx:${reqId}:no`);
|
|
66
|
+
|
|
67
|
+
let messageId: number | undefined;
|
|
68
|
+
let pinned = false;
|
|
69
|
+
try {
|
|
70
|
+
const msg = await this.api.sendMessage(chatId, body, {
|
|
71
|
+
reply_markup: kb,
|
|
72
|
+
disable_notification: false,
|
|
73
|
+
...threadExtra,
|
|
74
|
+
});
|
|
75
|
+
messageId = msg.message_id;
|
|
76
|
+
try {
|
|
77
|
+
await this.api.pinChatMessage(chatId, messageId, { disable_notification: true });
|
|
78
|
+
pinned = true;
|
|
79
|
+
} catch (e) {
|
|
80
|
+
log.warn("pin plan prompt failed:", (e as Error).message);
|
|
81
|
+
}
|
|
82
|
+
} catch (e) {
|
|
83
|
+
log.warn("send plan prompt failed:", (e as Error).message);
|
|
84
|
+
return { outcome: "approved", feedback: "" };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
return new Promise<PlanExitDecision>((resolve) => {
|
|
88
|
+
const timer = setTimeout(() => {
|
|
89
|
+
const p = this.pending.get(reqId);
|
|
90
|
+
if (!p) return;
|
|
91
|
+
this.pending.delete(reqId);
|
|
92
|
+
this.feedbackFor.delete(p.chatId);
|
|
93
|
+
void this.finish(p, "\u231B Plan approval timed out \u2014 abandoned.");
|
|
94
|
+
resolve({ outcome: "abandoned", feedback: "timed out waiting for review" });
|
|
95
|
+
}, TIMEOUT_MS);
|
|
96
|
+
this.pending.set(reqId, { resolve, chatId, messageId, timer, pinned, waitingFeedback: false });
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Button tap. Returns a short toast; "chg" waits for the next text message. */
|
|
101
|
+
resolveChoice(reqId: string, action: string): string | undefined {
|
|
102
|
+
const p = this.pending.get(reqId);
|
|
103
|
+
if (!p) return undefined;
|
|
104
|
+
if (action === "chg") {
|
|
105
|
+
p.waitingFeedback = true;
|
|
106
|
+
this.feedbackFor.set(p.chatId, reqId);
|
|
107
|
+
void this.api
|
|
108
|
+
.editMessageText(p.chatId, p.messageId ?? 0, "\u270F\uFE0F Send revision notes as your next message.", {
|
|
109
|
+
reply_markup: { inline_keyboard: [] },
|
|
110
|
+
})
|
|
111
|
+
.catch(() => {});
|
|
112
|
+
return "Send change notes";
|
|
113
|
+
}
|
|
114
|
+
clearTimeout(p.timer);
|
|
115
|
+
this.pending.delete(reqId);
|
|
116
|
+
this.feedbackFor.delete(p.chatId);
|
|
117
|
+
const outcome: PlanExitOutcome = action === "ok" ? "approved" : "abandoned";
|
|
118
|
+
const label = outcome === "approved" ? "\u2705 Plan approved \u2014 implementing." : "\u26D4 Plan abandoned.";
|
|
119
|
+
void this.finish(p, label);
|
|
120
|
+
p.resolve({ outcome, feedback: "" });
|
|
121
|
+
return outcome === "approved" ? "Approved" : "Abandoned";
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** If this chat is waiting for revision notes, consume the text. */
|
|
125
|
+
takeFeedback(chatId: number, text: string): boolean {
|
|
126
|
+
const reqId = this.feedbackFor.get(chatId);
|
|
127
|
+
if (!reqId) return false;
|
|
128
|
+
const p = this.pending.get(reqId);
|
|
129
|
+
this.feedbackFor.delete(chatId);
|
|
130
|
+
if (!p) return false;
|
|
131
|
+
clearTimeout(p.timer);
|
|
132
|
+
this.pending.delete(reqId);
|
|
133
|
+
const notes = text.trim().slice(0, 4000);
|
|
134
|
+
void this.finish(p, `\u270F\uFE0F Requested changes:\n${notes.slice(0, 400)}`);
|
|
135
|
+
p.resolve({ outcome: "request_changes", feedback: notes || "Please revise the plan." });
|
|
136
|
+
return true;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
private async finish(p: Pending, text: string): Promise<void> {
|
|
140
|
+
if (p.messageId !== undefined) {
|
|
141
|
+
await this.api
|
|
142
|
+
.editMessageText(p.chatId, p.messageId, text, { reply_markup: { inline_keyboard: [] } })
|
|
143
|
+
.catch(() => {});
|
|
144
|
+
}
|
|
145
|
+
if (p.pinned && p.messageId !== undefined) {
|
|
146
|
+
p.pinned = false;
|
|
147
|
+
await this.api.unpinChatMessage(p.chatId, p.messageId).catch(() => {});
|
|
148
|
+
}
|
|
149
|
+
if (this.onUnpinned) {
|
|
150
|
+
try {
|
|
151
|
+
await this.onUnpinned(p.chatId);
|
|
152
|
+
} catch {
|
|
153
|
+
/* non-fatal */
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
export function extractPlanText(params: Record<string, unknown>): string {
|
|
160
|
+
for (const k of ["plan_content", "planContent", "content", "plan", "text"]) {
|
|
161
|
+
const v = params[k];
|
|
162
|
+
if (typeof v === "string" && v.trim()) return v;
|
|
163
|
+
}
|
|
164
|
+
return "";
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function str(v: unknown): string {
|
|
168
|
+
return typeof v === "string" ? v : "";
|
|
169
|
+
}
|
package/src/bot/prompt-anchor.ts
CHANGED
|
@@ -15,6 +15,7 @@ import type { Api, Context } from "grammy";
|
|
|
15
15
|
import { InputMediaBuilder } from "grammy";
|
|
16
16
|
import { createLogger } from "../logger.js";
|
|
17
17
|
import { tagSafe } from "../render/hashtags.js";
|
|
18
|
+
import { outboundThreadExtra } from "../forum/thread.js";
|
|
18
19
|
|
|
19
20
|
const log = createLogger("prompt-anchor");
|
|
20
21
|
|
|
@@ -123,10 +124,8 @@ export async function adoptUserPrompt(
|
|
|
123
124
|
});
|
|
124
125
|
const threadExtra: Record<string, unknown> = {
|
|
125
126
|
disable_notification: true,
|
|
127
|
+
...outboundThreadExtra(opts.messageThreadId),
|
|
126
128
|
};
|
|
127
|
-
if (opts.messageThreadId !== undefined) {
|
|
128
|
-
threadExtra.message_thread_id = opts.messageThreadId;
|
|
129
|
-
}
|
|
130
129
|
|
|
131
130
|
let replyTo: number;
|
|
132
131
|
try {
|
|
@@ -69,6 +69,9 @@ export function mergeInputs(inputs: PromptInput[]): PromptInput {
|
|
|
69
69
|
// Preserve meta flags: auto-suggestion batches / self-recheck must not re-arm
|
|
70
70
|
// another recheck after merge (dropping this caused infinite recheck loops).
|
|
71
71
|
const skipSelfRecheck = inputs.some((i) => i.skipSelfRecheck);
|
|
72
|
+
// First reportBack wins (oldest queued manager dispatch in this merge batch).
|
|
73
|
+
const reportBack = inputs.find((i) => i.reportBack)?.reportBack;
|
|
74
|
+
const seedMessageId = inputs.find((i) => i.seedMessageId !== undefined)?.seedMessageId;
|
|
72
75
|
return {
|
|
73
76
|
text: inputs
|
|
74
77
|
.map((i) => i.text)
|
|
@@ -82,6 +85,8 @@ export function mergeInputs(inputs: PromptInput[]): PromptInput {
|
|
|
82
85
|
promptId: inputs.find((i) => i.promptId)?.promptId,
|
|
83
86
|
quotedText: quotes.length > 0 ? [...new Set(quotes)].join("\n\n---\n\n") : undefined,
|
|
84
87
|
skipSelfRecheck: skipSelfRecheck || undefined,
|
|
88
|
+
reportBack,
|
|
89
|
+
seedMessageId,
|
|
85
90
|
};
|
|
86
91
|
}
|
|
87
92
|
|
package/src/bot/registry.ts
CHANGED
|
@@ -24,6 +24,8 @@ import type { SessionRuntime } from "./session-runtime.js";
|
|
|
24
24
|
export interface SessionDescription {
|
|
25
25
|
/** Chat that owns the session (controlled session or subagent parent). */
|
|
26
26
|
chatId?: number;
|
|
27
|
+
/** Forum topic thread id when the session lives in a project topic. */
|
|
28
|
+
threadId?: number;
|
|
27
29
|
/** True when this is a session the chat directly controls. */
|
|
28
30
|
controlled: boolean;
|
|
29
31
|
/** True when this is a subagent of a controlled turn. */
|
|
@@ -202,10 +204,17 @@ export class RuntimeRegistry {
|
|
|
202
204
|
describeSession(sessionId: string): SessionDescription {
|
|
203
205
|
const controlledChat = this.findChatBySession(sessionId);
|
|
204
206
|
if (controlledChat !== undefined) {
|
|
205
|
-
const
|
|
207
|
+
const forum = this.forumControllerForSession(sessionId);
|
|
208
|
+
const project = (forum ?? this.controller(controlledChat))
|
|
206
209
|
.list()
|
|
207
210
|
.find((s) => s.sessionId === sessionId)?.projectName;
|
|
208
|
-
return {
|
|
211
|
+
return {
|
|
212
|
+
chatId: controlledChat,
|
|
213
|
+
threadId: forum?.messageThreadId,
|
|
214
|
+
controlled: true,
|
|
215
|
+
subagent: false,
|
|
216
|
+
projectName: project,
|
|
217
|
+
};
|
|
209
218
|
}
|
|
210
219
|
const parent = this.subagentParents.get(sessionId);
|
|
211
220
|
const info = this.acp.subagentById(sessionId);
|
package/src/bot/scope.ts
CHANGED
|
@@ -6,8 +6,11 @@ import type { Context } from "grammy";
|
|
|
6
6
|
import type { BotDeps } from "./deps.js";
|
|
7
7
|
import type { ChatController } from "./chat-controller.js";
|
|
8
8
|
import type { SessionRuntime } from "./session-runtime.js";
|
|
9
|
-
import {
|
|
10
|
-
|
|
9
|
+
import {
|
|
10
|
+
forumThreadId,
|
|
11
|
+
FORUM_GENERAL_THREAD_ID,
|
|
12
|
+
outboundThreadExtra,
|
|
13
|
+
} from "../forum/thread.js";
|
|
11
14
|
|
|
12
15
|
export interface HandlerScope {
|
|
13
16
|
chatId: number;
|
|
@@ -48,10 +51,6 @@ export function resolveScope(ctx: Context, deps: BotDeps): HandlerScope {
|
|
|
48
51
|
const chatId = ctx.chat!.id;
|
|
49
52
|
const rawThread = threadIdFromContext(ctx);
|
|
50
53
|
const isForum = Boolean(deps.forum?.isActiveForumChat(chatId));
|
|
51
|
-
const threadExtra =
|
|
52
|
-
isForum || rawThread !== undefined
|
|
53
|
-
? { message_thread_id: isForum ? forumThreadId(rawThread) : rawThread }
|
|
54
|
-
: {};
|
|
55
54
|
|
|
56
55
|
if (!isForum || !deps.forum) {
|
|
57
56
|
const controller = deps.registry.controller(chatId);
|
|
@@ -61,7 +60,8 @@ export function resolveScope(ctx: Context, deps: BotDeps): HandlerScope {
|
|
|
61
60
|
settingsKey: settingsKeyFor(chatId),
|
|
62
61
|
controller,
|
|
63
62
|
rt: controller.foreground(),
|
|
64
|
-
|
|
63
|
+
// Private chats rarely have threads; still omit General-style id 1.
|
|
64
|
+
threadExtra: outboundThreadExtra(rawThread),
|
|
65
65
|
};
|
|
66
66
|
}
|
|
67
67
|
|
|
@@ -87,7 +87,8 @@ export function resolveScope(ctx: Context, deps: BotDeps): HandlerScope {
|
|
|
87
87
|
settingsKey: settingsKeyFor(chatId, tid),
|
|
88
88
|
controller,
|
|
89
89
|
rt: controller.foreground(),
|
|
90
|
-
|
|
90
|
+
// Outbound: never pass message_thread_id=1 (General) — Telegram rejects it.
|
|
91
|
+
threadExtra: outboundThreadExtra(tid),
|
|
91
92
|
projectPath: cwd,
|
|
92
93
|
projectName,
|
|
93
94
|
};
|