grok-telegram-bot 2.5.0 → 2.6.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/CHANGELOG.md +71 -0
- package/README.md +5 -3
- package/docs/GROUP.md +39 -4
- package/package.json +4 -4
- package/src/app/types.ts +27 -1
- package/src/bot/auth.ts +5 -1
- package/src/bot/bot.ts +73 -4
- package/src/bot/chat-controller.ts +129 -0
- package/src/bot/group-memory.ts +192 -12
- package/src/bot/handlers/forum.ts +16 -6
- package/src/bot/handlers/message.ts +165 -25
- package/src/bot/handlers/photo.ts +4 -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/prompt-anchor.ts +2 -3
- package/src/bot/prompt-content.ts +5 -0
- package/src/bot/scope.ts +9 -8
- package/src/bot/session-runtime.ts +663 -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/forum/manager.ts +2 -1
- package/src/forum/thread.ts +33 -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/sessions/history.ts +18 -0
- package/src/stream/streamer.ts +46 -10
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Auto-injected context for General manager turns: topic catalog, memory hits,
|
|
3
|
+
* recent General chat history, and active dispatch jobs.
|
|
4
|
+
*/
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
import type { ForumManager } from "../forum/manager.js";
|
|
7
|
+
import { readHistory } from "../sessions/history.js";
|
|
8
|
+
import type { SessionStore } from "../sessions/store.js";
|
|
9
|
+
import { searchGroupMemory } from "./group-memory.js";
|
|
10
|
+
import { listActiveManagerJobs, listRecentManagerJobs, type ManagerJob } from "./manager-jobs.js";
|
|
11
|
+
|
|
12
|
+
export const MANAGER_CONTEXT_MARKER = "MANAGER CONTEXT (auto — use before dispatching work):";
|
|
13
|
+
|
|
14
|
+
const CONTEXT_MAX = 6500;
|
|
15
|
+
|
|
16
|
+
export interface ManagerContextOpts {
|
|
17
|
+
userText: string;
|
|
18
|
+
sessionsDir: string;
|
|
19
|
+
store: SessionStore;
|
|
20
|
+
forum?: ForumManager;
|
|
21
|
+
/** Override jobs list (tests). */
|
|
22
|
+
jobs?: ManagerJob[];
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Build a capped context block for manager prompts. */
|
|
26
|
+
export function buildManagerContextBlock(opts: ManagerContextOpts): string {
|
|
27
|
+
const lines: string[] = [MANAGER_CONTEXT_MARKER, ""];
|
|
28
|
+
|
|
29
|
+
const topics = opts.forum?.isReady ? opts.forum.store.all() : [];
|
|
30
|
+
lines.push("## Topics");
|
|
31
|
+
if (topics.length === 0) {
|
|
32
|
+
lines.push("(no forum topics mapped)");
|
|
33
|
+
} else {
|
|
34
|
+
const topicCap = 40;
|
|
35
|
+
for (const t of topics.slice(0, topicCap)) {
|
|
36
|
+
const path = t.projectPath ?? "(unbound)";
|
|
37
|
+
lines.push(`- **${t.name}** #${t.threadId} [${t.kind}] \`${path}\``);
|
|
38
|
+
}
|
|
39
|
+
if (topics.length > topicCap) {
|
|
40
|
+
lines.push(`… +${topics.length - topicCap} more (use list_topics)`);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const workspace =
|
|
45
|
+
topics.find((t) => t.kind === "general")?.projectPath ||
|
|
46
|
+
topics.find((t) => t.kind === "ai_chat")?.projectPath ||
|
|
47
|
+
undefined;
|
|
48
|
+
const preferPaths = [
|
|
49
|
+
workspace,
|
|
50
|
+
...topics
|
|
51
|
+
.filter((t) => t.kind === "project" && t.projectPath)
|
|
52
|
+
.map((t) => t.projectPath!)
|
|
53
|
+
.slice(0, 12),
|
|
54
|
+
].filter(Boolean) as string[];
|
|
55
|
+
|
|
56
|
+
// Always surface recent General chat so the manager "remembers" this room.
|
|
57
|
+
lines.push("", "## Recent General chat (always available)");
|
|
58
|
+
const generalSnips = recentGeneralHistory(opts, workspace, 10);
|
|
59
|
+
if (generalSnips.length === 0) {
|
|
60
|
+
lines.push("(no prior General history on disk yet)");
|
|
61
|
+
} else {
|
|
62
|
+
for (const s of generalSnips) lines.push(`- ${s}`);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
lines.push(
|
|
66
|
+
"",
|
|
67
|
+
"## Memory hits (ranked: relevance + recency — newest work first, not git)",
|
|
68
|
+
);
|
|
69
|
+
const hits = searchGroupMemory({
|
|
70
|
+
query: opts.userText,
|
|
71
|
+
limit: 14,
|
|
72
|
+
sessionsDir: opts.sessionsDir,
|
|
73
|
+
store: opts.store,
|
|
74
|
+
topics: topics.length ? topics : undefined,
|
|
75
|
+
preferPaths,
|
|
76
|
+
preferGeneral: true,
|
|
77
|
+
maxSessions: 32,
|
|
78
|
+
});
|
|
79
|
+
if (hits.length === 0) {
|
|
80
|
+
lines.push(
|
|
81
|
+
"(no hits — call search_memory; do NOT run git until memory is exhausted)",
|
|
82
|
+
);
|
|
83
|
+
} else {
|
|
84
|
+
for (const h of hits) {
|
|
85
|
+
const where =
|
|
86
|
+
h.threadId !== undefined
|
|
87
|
+
? ` #${h.threadId}`
|
|
88
|
+
: h.sessionId
|
|
89
|
+
? ` session=${h.sessionId.slice(0, 8)}`
|
|
90
|
+
: "";
|
|
91
|
+
lines.push(`- [${h.kind}] ${h.title}${where}: ${h.snippet}`);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
lines.push(
|
|
96
|
+
"",
|
|
97
|
+
"Rules: for \"last modifications / last work\" trust hits with [Xm/h/d ago] stamps — prefer the newest session for that project path.",
|
|
98
|
+
"Ignore older OmniRoute-style notes if a newer session for the same path exists.",
|
|
99
|
+
"Do not use git log/status as the first step unless the user asked for git.",
|
|
100
|
+
);
|
|
101
|
+
|
|
102
|
+
const jobs = opts.jobs ?? [...listActiveManagerJobs(8), ...listRecentManagerJobs(4)];
|
|
103
|
+
const seen = new Set<string>();
|
|
104
|
+
const uniq: ManagerJob[] = [];
|
|
105
|
+
for (const j of jobs) {
|
|
106
|
+
if (seen.has(j.id)) continue;
|
|
107
|
+
seen.add(j.id);
|
|
108
|
+
uniq.push(j);
|
|
109
|
+
if (uniq.length >= 10) break;
|
|
110
|
+
}
|
|
111
|
+
lines.push("", "## Manager jobs (this process)");
|
|
112
|
+
if (uniq.length === 0) {
|
|
113
|
+
lines.push("(none)");
|
|
114
|
+
} else {
|
|
115
|
+
for (const j of uniq) {
|
|
116
|
+
const ageMin = Math.max(0, Math.round((Date.now() - j.createdAt) / 60_000));
|
|
117
|
+
lines.push(
|
|
118
|
+
`- ${j.status} **${j.targetName}** #${j.targetThreadId} (${ageMin}m ago) job=${j.id}` +
|
|
119
|
+
(j.childSessionId ? ` session=${j.childSessionId.slice(0, 8)}` : ""),
|
|
120
|
+
);
|
|
121
|
+
if (j.userAskPreview) lines.push(` ask: ${clamp(j.userAskPreview, 160)}`);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
lines.push(
|
|
126
|
+
"",
|
|
127
|
+
"Use this context to pick the right topic and write a strong send_prompt.",
|
|
128
|
+
"When a hit shows session=XXXXXXXX and the user wants follow-up there, pass that exact",
|
|
129
|
+
"prefix as session_id on send_prompt (do NOT rely on the topic's currently open session).",
|
|
130
|
+
"Prefer search_memory again if you need deeper history before dispatching.",
|
|
131
|
+
);
|
|
132
|
+
|
|
133
|
+
let block = lines.join("\n");
|
|
134
|
+
if (block.length > CONTEXT_MAX) {
|
|
135
|
+
block = block.slice(0, CONTEXT_MAX - 1) + "\u2026";
|
|
136
|
+
}
|
|
137
|
+
return block;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** Last user/assistant lines from General-named or workspace sessions. */
|
|
141
|
+
function recentGeneralHistory(
|
|
142
|
+
opts: ManagerContextOpts,
|
|
143
|
+
workspace: string | undefined,
|
|
144
|
+
limit: number,
|
|
145
|
+
): string[] {
|
|
146
|
+
const out: string[] = [];
|
|
147
|
+
let metas;
|
|
148
|
+
try {
|
|
149
|
+
metas = opts.store.list(40);
|
|
150
|
+
} catch {
|
|
151
|
+
return out;
|
|
152
|
+
}
|
|
153
|
+
const ws = workspace?.replace(/\\/g, "/").toLowerCase();
|
|
154
|
+
const ranked = metas
|
|
155
|
+
.map((m) => {
|
|
156
|
+
const title = (m.title || "").toLowerCase();
|
|
157
|
+
const cwd = (m.cwd || "").replace(/\\/g, "/").toLowerCase();
|
|
158
|
+
let rank = 0;
|
|
159
|
+
// Prefer sessions explicitly labeled General — never treat every project
|
|
160
|
+
// under the workspace root as "General chat" (path prefix trap).
|
|
161
|
+
if (title === "general") rank += 12;
|
|
162
|
+
else if (/\bgeneral\b/.test(title)) rank += 6;
|
|
163
|
+
// Exact workspace cwd only (General/AI Chat bind), not child project paths.
|
|
164
|
+
if (ws && cwd === ws) rank += 4;
|
|
165
|
+
return { m, rank };
|
|
166
|
+
})
|
|
167
|
+
.filter((x) => x.rank > 0)
|
|
168
|
+
.sort(
|
|
169
|
+
(a, b) =>
|
|
170
|
+
b.rank - a.rank ||
|
|
171
|
+
String(b.m.updatedAt || "").localeCompare(String(a.m.updatedAt || "")),
|
|
172
|
+
);
|
|
173
|
+
|
|
174
|
+
for (const { m } of ranked.slice(0, 4)) {
|
|
175
|
+
try {
|
|
176
|
+
const path = join(opts.sessionsDir, `${m.sessionId}.jsonl`);
|
|
177
|
+
const entries = readHistory(path, 8);
|
|
178
|
+
for (const e of entries) {
|
|
179
|
+
if (!e.text?.trim()) continue;
|
|
180
|
+
// Skip huge manager context dumps in history.
|
|
181
|
+
if (e.text.includes(MANAGER_CONTEXT_MARKER)) continue;
|
|
182
|
+
if (e.text.startsWith("MANAGER MODE")) continue;
|
|
183
|
+
const role = e.role === "user" ? "user" : e.role === "assistant" ? "bot" : e.role;
|
|
184
|
+
out.push(
|
|
185
|
+
`${role} · ${m.sessionId.slice(0, 8)}: ${clamp(e.text.replace(/\s+/g, " "), 180)}`,
|
|
186
|
+
);
|
|
187
|
+
if (out.length >= limit) return out;
|
|
188
|
+
}
|
|
189
|
+
} catch {
|
|
190
|
+
/* skip */
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
return out;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/** Prepend context block to a user/manager prompt body (idempotent). */
|
|
197
|
+
export function injectManagerContext(text: string, contextBlock: string): string {
|
|
198
|
+
const body = text.trim();
|
|
199
|
+
if (!contextBlock.trim()) return body;
|
|
200
|
+
if (body.includes(MANAGER_CONTEXT_MARKER)) return body;
|
|
201
|
+
return `${contextBlock}\n\n---\n\n${body}`;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function clamp(s: string, max: number): string {
|
|
205
|
+
const t = s.replace(/\s+/g, " ").trim();
|
|
206
|
+
if (t.length <= max) return t;
|
|
207
|
+
return t.slice(0, max - 1) + "\u2026";
|
|
208
|
+
}
|
|
@@ -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 () => {
|
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/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
|
};
|