opencode-telegram-group-topics-bot 0.11.2
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 +74 -0
- package/LICENSE +21 -0
- package/README.md +305 -0
- package/dist/agent/manager.js +60 -0
- package/dist/agent/types.js +26 -0
- package/dist/app/start-bot-app.js +47 -0
- package/dist/bot/commands/abort.js +116 -0
- package/dist/bot/commands/commands.js +389 -0
- package/dist/bot/commands/constants.js +20 -0
- package/dist/bot/commands/definitions.js +25 -0
- package/dist/bot/commands/help.js +27 -0
- package/dist/bot/commands/models.js +38 -0
- package/dist/bot/commands/new.js +247 -0
- package/dist/bot/commands/opencode-start.js +85 -0
- package/dist/bot/commands/opencode-stop.js +44 -0
- package/dist/bot/commands/projects.js +304 -0
- package/dist/bot/commands/rename.js +173 -0
- package/dist/bot/commands/sessions.js +491 -0
- package/dist/bot/commands/start.js +67 -0
- package/dist/bot/commands/status.js +138 -0
- package/dist/bot/constants.js +49 -0
- package/dist/bot/handlers/agent.js +127 -0
- package/dist/bot/handlers/context.js +125 -0
- package/dist/bot/handlers/document.js +65 -0
- package/dist/bot/handlers/inline-menu.js +124 -0
- package/dist/bot/handlers/model.js +152 -0
- package/dist/bot/handlers/permission.js +281 -0
- package/dist/bot/handlers/prompt.js +263 -0
- package/dist/bot/handlers/question.js +285 -0
- package/dist/bot/handlers/variant.js +147 -0
- package/dist/bot/handlers/voice.js +173 -0
- package/dist/bot/index.js +945 -0
- package/dist/bot/message-patterns.js +4 -0
- package/dist/bot/middleware/auth.js +30 -0
- package/dist/bot/middleware/interaction-guard.js +80 -0
- package/dist/bot/middleware/unknown-command.js +22 -0
- package/dist/bot/scope.js +222 -0
- package/dist/bot/telegram-constants.js +3 -0
- package/dist/bot/telegram-rate-limiter.js +263 -0
- package/dist/bot/utils/commands.js +21 -0
- package/dist/bot/utils/file-download.js +91 -0
- package/dist/bot/utils/keyboard.js +85 -0
- package/dist/bot/utils/send-with-markdown-fallback.js +57 -0
- package/dist/bot/utils/session-error-filter.js +34 -0
- package/dist/bot/utils/topic-link.js +29 -0
- package/dist/cli/args.js +98 -0
- package/dist/cli.js +80 -0
- package/dist/config.js +103 -0
- package/dist/i18n/de.js +330 -0
- package/dist/i18n/en.js +330 -0
- package/dist/i18n/es.js +330 -0
- package/dist/i18n/index.js +102 -0
- package/dist/i18n/ru.js +330 -0
- package/dist/i18n/zh.js +330 -0
- package/dist/index.js +28 -0
- package/dist/interaction/cleanup.js +24 -0
- package/dist/interaction/constants.js +25 -0
- package/dist/interaction/guard.js +100 -0
- package/dist/interaction/manager.js +113 -0
- package/dist/interaction/types.js +1 -0
- package/dist/keyboard/manager.js +115 -0
- package/dist/keyboard/types.js +1 -0
- package/dist/model/capabilities.js +62 -0
- package/dist/model/manager.js +257 -0
- package/dist/model/types.js +24 -0
- package/dist/opencode/client.js +13 -0
- package/dist/opencode/events.js +159 -0
- package/dist/opencode/prompt-submit-error.js +101 -0
- package/dist/permission/manager.js +92 -0
- package/dist/permission/types.js +1 -0
- package/dist/pinned/manager.js +405 -0
- package/dist/pinned/types.js +1 -0
- package/dist/process/manager.js +273 -0
- package/dist/process/types.js +1 -0
- package/dist/project/manager.js +88 -0
- package/dist/question/manager.js +186 -0
- package/dist/question/types.js +1 -0
- package/dist/rename/manager.js +64 -0
- package/dist/runtime/bootstrap.js +350 -0
- package/dist/runtime/mode.js +74 -0
- package/dist/runtime/paths.js +37 -0
- package/dist/runtime/process-error-handlers.js +24 -0
- package/dist/session/cache-manager.js +455 -0
- package/dist/session/manager.js +87 -0
- package/dist/settings/manager.js +283 -0
- package/dist/stt/client.js +64 -0
- package/dist/summary/aggregator.js +625 -0
- package/dist/summary/formatter.js +417 -0
- package/dist/summary/tool-message-batcher.js +277 -0
- package/dist/topic/colors.js +8 -0
- package/dist/topic/constants.js +10 -0
- package/dist/topic/manager.js +161 -0
- package/dist/topic/title-constants.js +2 -0
- package/dist/topic/title-format.js +10 -0
- package/dist/topic/title-sync.js +17 -0
- package/dist/utils/error-format.js +29 -0
- package/dist/utils/logger.js +175 -0
- package/dist/utils/safe-background-task.js +33 -0
- package/dist/variant/manager.js +103 -0
- package/dist/variant/types.js +1 -0
- package/package.json +76 -0
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { TOPIC_SESSION_STATUS } from "../settings/manager.js";
|
|
2
|
+
export const TOPIC_CLEANUP_POLICY = {
|
|
3
|
+
CLOSE_ONLY: "close-only",
|
|
4
|
+
};
|
|
5
|
+
export const TOPIC_STATUS_CLOSEABLE = new Set([
|
|
6
|
+
TOPIC_SESSION_STATUS.CLOSED,
|
|
7
|
+
TOPIC_SESSION_STATUS.STALE,
|
|
8
|
+
TOPIC_SESSION_STATUS.ABANDONED,
|
|
9
|
+
TOPIC_SESSION_STATUS.ERROR,
|
|
10
|
+
]);
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
import { TOPIC_SESSION_STATUS, clearTopicSessionBinding, getCurrentProject, getScopedSessions, findTopicSessionBindingByScopeKey, findTopicSessionBindingBySessionId, getTopicSessionBinding, getTopicSessionBindings, getTopicSessionBindingsByChat, setTopicSessionBinding, updateTopicSessionBindingStatus, } from "../settings/manager.js";
|
|
2
|
+
import { getScopeForSession } from "../session/manager.js";
|
|
3
|
+
import { SCOPE_CONTEXT, getScopeFromKey } from "../bot/scope.js";
|
|
4
|
+
import { logger } from "../utils/logger.js";
|
|
5
|
+
const BINDING_KEY_SEPARATOR = ":";
|
|
6
|
+
let hydratedFromScopedSessions = false;
|
|
7
|
+
function nowTimestamp() {
|
|
8
|
+
return Date.now();
|
|
9
|
+
}
|
|
10
|
+
export function createTopicBindingKey(chatId, threadId) {
|
|
11
|
+
return `${chatId}${BINDING_KEY_SEPARATOR}${threadId}`;
|
|
12
|
+
}
|
|
13
|
+
function buildBinding(input, existing) {
|
|
14
|
+
const timestamp = nowTimestamp();
|
|
15
|
+
return {
|
|
16
|
+
scopeKey: input.scopeKey,
|
|
17
|
+
chatId: input.chatId,
|
|
18
|
+
threadId: input.threadId,
|
|
19
|
+
sessionId: input.sessionId,
|
|
20
|
+
projectId: input.projectId,
|
|
21
|
+
projectWorktree: input.projectWorktree,
|
|
22
|
+
topicName: input.topicName,
|
|
23
|
+
status: input.status ?? existing?.status ?? TOPIC_SESSION_STATUS.ACTIVE,
|
|
24
|
+
createdAt: existing?.createdAt ?? timestamp,
|
|
25
|
+
updatedAt: timestamp,
|
|
26
|
+
closedAt: existing?.closedAt,
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
function ensureTopicBindingsHydrated() {
|
|
30
|
+
if (hydratedFromScopedSessions) {
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
hydratedFromScopedSessions = true;
|
|
34
|
+
const scopedSessions = getScopedSessions();
|
|
35
|
+
for (const [scopeKey, sessionInfo] of Object.entries(scopedSessions)) {
|
|
36
|
+
const scope = getScopeFromKey(scopeKey);
|
|
37
|
+
if (!scope || scope.context !== SCOPE_CONTEXT.GROUP_TOPIC || scope.threadId === null) {
|
|
38
|
+
continue;
|
|
39
|
+
}
|
|
40
|
+
const bindingKey = createTopicBindingKey(scope.chatId, scope.threadId);
|
|
41
|
+
const existingByTopic = getTopicSessionBinding(bindingKey);
|
|
42
|
+
const existingBySession = findTopicSessionBindingBySessionId(sessionInfo.id);
|
|
43
|
+
if (existingByTopic || existingBySession) {
|
|
44
|
+
continue;
|
|
45
|
+
}
|
|
46
|
+
const project = getCurrentProject(scopeKey);
|
|
47
|
+
if (!project) {
|
|
48
|
+
continue;
|
|
49
|
+
}
|
|
50
|
+
const timestamp = nowTimestamp();
|
|
51
|
+
const hydratedBinding = {
|
|
52
|
+
scopeKey,
|
|
53
|
+
chatId: scope.chatId,
|
|
54
|
+
threadId: scope.threadId,
|
|
55
|
+
sessionId: sessionInfo.id,
|
|
56
|
+
projectId: project.id,
|
|
57
|
+
projectWorktree: project.worktree,
|
|
58
|
+
topicName: undefined,
|
|
59
|
+
status: TOPIC_SESSION_STATUS.ACTIVE,
|
|
60
|
+
createdAt: timestamp,
|
|
61
|
+
updatedAt: timestamp,
|
|
62
|
+
};
|
|
63
|
+
setTopicSessionBinding(bindingKey, hydratedBinding);
|
|
64
|
+
logger.info(`[TopicManager] Hydrated binding from scoped session: scope=${scopeKey}, session=${sessionInfo.id}`);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
export function registerTopicSessionBinding(input) {
|
|
68
|
+
ensureTopicBindingsHydrated();
|
|
69
|
+
const bindingKey = createTopicBindingKey(input.chatId, input.threadId);
|
|
70
|
+
const existingByTopic = getTopicSessionBinding(bindingKey);
|
|
71
|
+
if (existingByTopic && existingByTopic.sessionId !== input.sessionId) {
|
|
72
|
+
throw new Error(`[TopicManager] Topic ${bindingKey} is already bound to session ${existingByTopic.sessionId}`);
|
|
73
|
+
}
|
|
74
|
+
const existingBySession = findTopicSessionBindingBySessionId(input.sessionId);
|
|
75
|
+
if (existingBySession) {
|
|
76
|
+
const existingBySessionKey = createTopicBindingKey(existingBySession.chatId, existingBySession.threadId);
|
|
77
|
+
if (existingBySessionKey !== bindingKey) {
|
|
78
|
+
clearTopicSessionBinding(existingBySessionKey);
|
|
79
|
+
logger.warn(`[TopicManager] Rebinding session ${input.sessionId} from topic ${existingBySessionKey} to ${bindingKey}`);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
const nextBinding = buildBinding(input, existingByTopic);
|
|
83
|
+
setTopicSessionBinding(bindingKey, nextBinding);
|
|
84
|
+
logger.info(`[TopicManager] Registered topic binding: scope=${nextBinding.scopeKey}, session=${nextBinding.sessionId}, status=${nextBinding.status}`);
|
|
85
|
+
return nextBinding;
|
|
86
|
+
}
|
|
87
|
+
export function getTopicBinding(chatId, threadId) {
|
|
88
|
+
ensureTopicBindingsHydrated();
|
|
89
|
+
return getTopicSessionBinding(createTopicBindingKey(chatId, threadId));
|
|
90
|
+
}
|
|
91
|
+
export function getTopicBindingByScopeKey(scopeKey) {
|
|
92
|
+
ensureTopicBindingsHydrated();
|
|
93
|
+
return findTopicSessionBindingByScopeKey(scopeKey);
|
|
94
|
+
}
|
|
95
|
+
export function getTopicBindingBySessionId(sessionId) {
|
|
96
|
+
ensureTopicBindingsHydrated();
|
|
97
|
+
return findTopicSessionBindingBySessionId(sessionId);
|
|
98
|
+
}
|
|
99
|
+
export function getTopicBindingsByChat(chatId) {
|
|
100
|
+
ensureTopicBindingsHydrated();
|
|
101
|
+
return getTopicSessionBindingsByChat(chatId);
|
|
102
|
+
}
|
|
103
|
+
export function listAllTopicBindings() {
|
|
104
|
+
ensureTopicBindingsHydrated();
|
|
105
|
+
return Object.values(getTopicSessionBindings());
|
|
106
|
+
}
|
|
107
|
+
export function updateTopicBindingStatus(chatId, threadId, status) {
|
|
108
|
+
ensureTopicBindingsHydrated();
|
|
109
|
+
const bindingKey = createTopicBindingKey(chatId, threadId);
|
|
110
|
+
updateTopicSessionBindingStatus(bindingKey, status);
|
|
111
|
+
}
|
|
112
|
+
export function updateTopicBindingStatusBySessionId(sessionId, status) {
|
|
113
|
+
ensureTopicBindingsHydrated();
|
|
114
|
+
const binding = findTopicSessionBindingBySessionId(sessionId);
|
|
115
|
+
if (!binding) {
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
updateTopicSessionBindingStatus(createTopicBindingKey(binding.chatId, binding.threadId), status);
|
|
119
|
+
}
|
|
120
|
+
export function updateTopicBindingNameBySessionId(sessionId, topicName) {
|
|
121
|
+
ensureTopicBindingsHydrated();
|
|
122
|
+
const binding = findTopicSessionBindingBySessionId(sessionId);
|
|
123
|
+
if (!binding) {
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
const bindingKey = createTopicBindingKey(binding.chatId, binding.threadId);
|
|
127
|
+
const nextBinding = {
|
|
128
|
+
...binding,
|
|
129
|
+
topicName,
|
|
130
|
+
updatedAt: nowTimestamp(),
|
|
131
|
+
};
|
|
132
|
+
setTopicSessionBinding(bindingKey, nextBinding);
|
|
133
|
+
}
|
|
134
|
+
export function removeTopicBinding(chatId, threadId) {
|
|
135
|
+
ensureTopicBindingsHydrated();
|
|
136
|
+
clearTopicSessionBinding(createTopicBindingKey(chatId, threadId));
|
|
137
|
+
}
|
|
138
|
+
export function getSessionRouteTarget(sessionId) {
|
|
139
|
+
ensureTopicBindingsHydrated();
|
|
140
|
+
const binding = findTopicSessionBindingBySessionId(sessionId);
|
|
141
|
+
if (binding) {
|
|
142
|
+
return {
|
|
143
|
+
scopeKey: binding.scopeKey,
|
|
144
|
+
chatId: binding.chatId,
|
|
145
|
+
threadId: binding.threadId,
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
const scopeKey = getScopeForSession(sessionId);
|
|
149
|
+
if (!scopeKey) {
|
|
150
|
+
return null;
|
|
151
|
+
}
|
|
152
|
+
const scope = getScopeFromKey(scopeKey);
|
|
153
|
+
if (!scope) {
|
|
154
|
+
return null;
|
|
155
|
+
}
|
|
156
|
+
return {
|
|
157
|
+
scopeKey,
|
|
158
|
+
chatId: scope.chatId,
|
|
159
|
+
threadId: scope.threadId,
|
|
160
|
+
};
|
|
161
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { TOPIC_NAME_MAX_LENGTH, TOPIC_NAME_TRUNCATION_SUFFIX } from "./title-constants.js";
|
|
2
|
+
const TOPIC_TITLE_FALLBACK = "Session";
|
|
3
|
+
export function formatTopicTitle(rawTitle, fallbackTitle) {
|
|
4
|
+
const baseTitle = rawTitle.trim() || fallbackTitle?.trim() || TOPIC_TITLE_FALLBACK;
|
|
5
|
+
if (baseTitle.length <= TOPIC_NAME_MAX_LENGTH) {
|
|
6
|
+
return baseTitle;
|
|
7
|
+
}
|
|
8
|
+
const allowedLength = TOPIC_NAME_MAX_LENGTH - TOPIC_NAME_TRUNCATION_SUFFIX.length;
|
|
9
|
+
return `${baseTitle.slice(0, allowedLength).trimEnd()}${TOPIC_NAME_TRUNCATION_SUFFIX}`;
|
|
10
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { logger } from "../utils/logger.js";
|
|
2
|
+
import { getTopicBindingBySessionId, updateTopicBindingNameBySessionId } from "./manager.js";
|
|
3
|
+
import { formatTopicTitle } from "./title-format.js";
|
|
4
|
+
export async function syncTopicTitleForSession(api, sessionId, sessionTitle) {
|
|
5
|
+
const binding = getTopicBindingBySessionId(sessionId);
|
|
6
|
+
if (!binding) {
|
|
7
|
+
return false;
|
|
8
|
+
}
|
|
9
|
+
const topicName = formatTopicTitle(sessionTitle);
|
|
10
|
+
if (!topicName || binding.topicName === topicName) {
|
|
11
|
+
return false;
|
|
12
|
+
}
|
|
13
|
+
await api.editForumTopic(binding.chatId, binding.threadId, { name: topicName });
|
|
14
|
+
updateTopicBindingNameBySessionId(sessionId, topicName);
|
|
15
|
+
logger.info(`[TopicTitle] Synced topic title for session ${sessionId}: thread=${binding.threadId}, title=${topicName}`);
|
|
16
|
+
return true;
|
|
17
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
const DEFAULT_MAX_ERROR_DETAILS_LENGTH = 1500;
|
|
2
|
+
function clipText(value, maxLength) {
|
|
3
|
+
if (value.length <= maxLength) {
|
|
4
|
+
return value;
|
|
5
|
+
}
|
|
6
|
+
return `${value.slice(0, Math.max(0, maxLength - 3))}...`;
|
|
7
|
+
}
|
|
8
|
+
export function formatErrorDetails(error, maxLength = DEFAULT_MAX_ERROR_DETAILS_LENGTH) {
|
|
9
|
+
let details = "";
|
|
10
|
+
if (error instanceof Error) {
|
|
11
|
+
details = error.stack ?? `${error.name}: ${error.message}`;
|
|
12
|
+
}
|
|
13
|
+
else if (typeof error === "string") {
|
|
14
|
+
details = error;
|
|
15
|
+
}
|
|
16
|
+
else {
|
|
17
|
+
try {
|
|
18
|
+
details = JSON.stringify(error, null, 2);
|
|
19
|
+
}
|
|
20
|
+
catch {
|
|
21
|
+
details = String(error);
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
const normalized = details.trim();
|
|
25
|
+
if (!normalized || normalized === "{}" || normalized === "[object Object]") {
|
|
26
|
+
return "unknown error";
|
|
27
|
+
}
|
|
28
|
+
return clipText(normalized, maxLength);
|
|
29
|
+
}
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
import { promises as fs } from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { config } from "../config.js";
|
|
4
|
+
import { getRuntimePaths } from "../runtime/paths.js";
|
|
5
|
+
const LOG_LEVELS = {
|
|
6
|
+
debug: 0,
|
|
7
|
+
info: 1,
|
|
8
|
+
warn: 2,
|
|
9
|
+
error: 3,
|
|
10
|
+
};
|
|
11
|
+
const FILE_LOG_NAME = "opencode-telegram-group-topics-bot.log";
|
|
12
|
+
const FILE_LOG_MAX_BYTES = 5 * 1024 * 1024;
|
|
13
|
+
const FILE_LOG_MAX_ROTATIONS = 5;
|
|
14
|
+
const LOG_LINE_SEPARATOR = "\n";
|
|
15
|
+
const LOG_FILE_SUFFIX_SEPARATOR = ".";
|
|
16
|
+
let fileLoggerInitialized = false;
|
|
17
|
+
let currentLogFileSizeBytes = 0;
|
|
18
|
+
let fileWriteQueue = Promise.resolve();
|
|
19
|
+
function normalizeLogLevel(value) {
|
|
20
|
+
if (value in LOG_LEVELS) {
|
|
21
|
+
return value;
|
|
22
|
+
}
|
|
23
|
+
return "info";
|
|
24
|
+
}
|
|
25
|
+
function formatPrefix(level) {
|
|
26
|
+
return `[${new Date().toISOString()}] [${level.toUpperCase()}]`;
|
|
27
|
+
}
|
|
28
|
+
function formatArg(arg) {
|
|
29
|
+
if (arg instanceof Error) {
|
|
30
|
+
return arg.stack ?? `${arg.name}: ${arg.message}`;
|
|
31
|
+
}
|
|
32
|
+
return arg;
|
|
33
|
+
}
|
|
34
|
+
function withPrefix(level, args) {
|
|
35
|
+
const formattedArgs = args.map((arg) => formatArg(arg));
|
|
36
|
+
const prefix = formatPrefix(level);
|
|
37
|
+
if (formattedArgs.length === 0) {
|
|
38
|
+
return [prefix];
|
|
39
|
+
}
|
|
40
|
+
if (typeof formattedArgs[0] === "string") {
|
|
41
|
+
return [`${prefix} ${formattedArgs[0]}`, ...formattedArgs.slice(1)];
|
|
42
|
+
}
|
|
43
|
+
return [prefix, ...formattedArgs];
|
|
44
|
+
}
|
|
45
|
+
function shouldLog(level) {
|
|
46
|
+
const configLevel = normalizeLogLevel(config.server.logLevel);
|
|
47
|
+
return LOG_LEVELS[level] >= LOG_LEVELS[configLevel];
|
|
48
|
+
}
|
|
49
|
+
function getLogFilePath() {
|
|
50
|
+
return path.join(getRuntimePaths().logsDirPath, FILE_LOG_NAME);
|
|
51
|
+
}
|
|
52
|
+
function getRotatedLogPath(logFilePath, index) {
|
|
53
|
+
return `${logFilePath}${LOG_FILE_SUFFIX_SEPARATOR}${index}`;
|
|
54
|
+
}
|
|
55
|
+
async function pathExists(filePath) {
|
|
56
|
+
try {
|
|
57
|
+
await fs.stat(filePath);
|
|
58
|
+
return true;
|
|
59
|
+
}
|
|
60
|
+
catch {
|
|
61
|
+
return false;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
async function ensureFileLoggerInitialized(logFilePath) {
|
|
65
|
+
if (fileLoggerInitialized) {
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
await fs.mkdir(path.dirname(logFilePath), { recursive: true });
|
|
69
|
+
try {
|
|
70
|
+
const stat = await fs.stat(logFilePath);
|
|
71
|
+
currentLogFileSizeBytes = stat.size;
|
|
72
|
+
}
|
|
73
|
+
catch {
|
|
74
|
+
currentLogFileSizeBytes = 0;
|
|
75
|
+
}
|
|
76
|
+
fileLoggerInitialized = true;
|
|
77
|
+
}
|
|
78
|
+
async function rotateLogFile(logFilePath) {
|
|
79
|
+
const oldestPath = getRotatedLogPath(logFilePath, FILE_LOG_MAX_ROTATIONS);
|
|
80
|
+
if (await pathExists(oldestPath)) {
|
|
81
|
+
await fs.rm(oldestPath, { force: true });
|
|
82
|
+
}
|
|
83
|
+
for (let index = FILE_LOG_MAX_ROTATIONS - 1; index >= 1; index--) {
|
|
84
|
+
const currentPath = getRotatedLogPath(logFilePath, index);
|
|
85
|
+
if (!(await pathExists(currentPath))) {
|
|
86
|
+
continue;
|
|
87
|
+
}
|
|
88
|
+
const nextPath = getRotatedLogPath(logFilePath, index + 1);
|
|
89
|
+
await fs.rename(currentPath, nextPath);
|
|
90
|
+
}
|
|
91
|
+
if (await pathExists(logFilePath)) {
|
|
92
|
+
await fs.rename(logFilePath, getRotatedLogPath(logFilePath, 1));
|
|
93
|
+
}
|
|
94
|
+
currentLogFileSizeBytes = 0;
|
|
95
|
+
}
|
|
96
|
+
function stringifyLogArg(arg) {
|
|
97
|
+
if (arg instanceof Error) {
|
|
98
|
+
return arg.stack ?? `${arg.name}: ${arg.message}`;
|
|
99
|
+
}
|
|
100
|
+
if (typeof arg === "string") {
|
|
101
|
+
return arg;
|
|
102
|
+
}
|
|
103
|
+
if (typeof arg === "number" || typeof arg === "boolean" || arg === null || arg === undefined) {
|
|
104
|
+
return String(arg);
|
|
105
|
+
}
|
|
106
|
+
try {
|
|
107
|
+
return JSON.stringify(arg);
|
|
108
|
+
}
|
|
109
|
+
catch {
|
|
110
|
+
return String(arg);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
function toFileLogLine(level, args) {
|
|
114
|
+
const prefix = formatPrefix(level);
|
|
115
|
+
if (args.length === 0) {
|
|
116
|
+
return prefix;
|
|
117
|
+
}
|
|
118
|
+
const serializedArgs = args.map((arg) => stringifyLogArg(arg));
|
|
119
|
+
return `${prefix} ${serializedArgs.join(" ")}`;
|
|
120
|
+
}
|
|
121
|
+
function writeToConsole(level, args) {
|
|
122
|
+
const output = withPrefix(level, args);
|
|
123
|
+
if (level === "warn") {
|
|
124
|
+
console.warn(...output);
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
if (level === "error") {
|
|
128
|
+
console.error(...output);
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
console.log(...output);
|
|
132
|
+
}
|
|
133
|
+
function writeToFile(level, args) {
|
|
134
|
+
const logFilePath = getLogFilePath();
|
|
135
|
+
const logLine = `${toFileLogLine(level, args)}${LOG_LINE_SEPARATOR}`;
|
|
136
|
+
fileWriteQueue = fileWriteQueue
|
|
137
|
+
.catch(() => {
|
|
138
|
+
// Keep queue alive after previous write error.
|
|
139
|
+
})
|
|
140
|
+
.then(async () => {
|
|
141
|
+
try {
|
|
142
|
+
await ensureFileLoggerInitialized(logFilePath);
|
|
143
|
+
const lineSizeBytes = Buffer.byteLength(logLine);
|
|
144
|
+
if (currentLogFileSizeBytes + lineSizeBytes > FILE_LOG_MAX_BYTES) {
|
|
145
|
+
await rotateLogFile(logFilePath);
|
|
146
|
+
}
|
|
147
|
+
await fs.appendFile(logFilePath, logLine, "utf-8");
|
|
148
|
+
currentLogFileSizeBytes += lineSizeBytes;
|
|
149
|
+
}
|
|
150
|
+
catch (error) {
|
|
151
|
+
console.error("[Logger] Failed to write log file:", error);
|
|
152
|
+
}
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
function log(level, ...args) {
|
|
156
|
+
if (!shouldLog(level)) {
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
writeToConsole(level, args);
|
|
160
|
+
writeToFile(level, args);
|
|
161
|
+
}
|
|
162
|
+
export const logger = {
|
|
163
|
+
debug: (...args) => {
|
|
164
|
+
log("debug", ...args);
|
|
165
|
+
},
|
|
166
|
+
info: (...args) => {
|
|
167
|
+
log("info", ...args);
|
|
168
|
+
},
|
|
169
|
+
warn: (...args) => {
|
|
170
|
+
log("warn", ...args);
|
|
171
|
+
},
|
|
172
|
+
error: (...args) => {
|
|
173
|
+
log("error", ...args);
|
|
174
|
+
},
|
|
175
|
+
};
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { logger } from "./logger.js";
|
|
2
|
+
function runHookSafely(taskName, hookName, hook, value) {
|
|
3
|
+
if (!hook) {
|
|
4
|
+
return;
|
|
5
|
+
}
|
|
6
|
+
try {
|
|
7
|
+
void Promise.resolve(hook(value)).catch((hookError) => {
|
|
8
|
+
logger.error(`[safeBackgroundTask] ${taskName}: ${hookName} failed:`, hookError);
|
|
9
|
+
});
|
|
10
|
+
}
|
|
11
|
+
catch (hookError) {
|
|
12
|
+
logger.error(`[safeBackgroundTask] ${taskName}: ${hookName} failed:`, hookError);
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
export function safeBackgroundTask({ taskName, task, onSuccess, onError, }) {
|
|
16
|
+
const handleError = (error) => {
|
|
17
|
+
logger.error(`[safeBackgroundTask] ${taskName} failed:`, error);
|
|
18
|
+
runHookSafely(taskName, "onError", onError, error);
|
|
19
|
+
};
|
|
20
|
+
try {
|
|
21
|
+
const taskPromise = task();
|
|
22
|
+
void taskPromise
|
|
23
|
+
.then((result) => {
|
|
24
|
+
runHookSafely(taskName, "onSuccess", onSuccess, result);
|
|
25
|
+
})
|
|
26
|
+
.catch((error) => {
|
|
27
|
+
handleError(error);
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
catch (error) {
|
|
31
|
+
handleError(error);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Variant Manager - manages model variants (reasoning modes)
|
|
3
|
+
*/
|
|
4
|
+
import { opencodeClient } from "../opencode/client.js";
|
|
5
|
+
import { getCurrentModel, setCurrentModel } from "../settings/manager.js";
|
|
6
|
+
import { logger } from "../utils/logger.js";
|
|
7
|
+
/**
|
|
8
|
+
* Get available variants for a model from OpenCode API
|
|
9
|
+
* @param providerID Provider ID
|
|
10
|
+
* @param modelID Model ID
|
|
11
|
+
* @returns Array of available variants
|
|
12
|
+
*/
|
|
13
|
+
export async function getAvailableVariants(providerID, modelID) {
|
|
14
|
+
try {
|
|
15
|
+
const { data, error } = await opencodeClient.config.providers();
|
|
16
|
+
if (error || !data) {
|
|
17
|
+
logger.warn("[VariantManager] Failed to fetch providers:", error);
|
|
18
|
+
return [{ id: "default" }];
|
|
19
|
+
}
|
|
20
|
+
const provider = data.providers.find((p) => p.id === providerID);
|
|
21
|
+
if (!provider) {
|
|
22
|
+
logger.warn(`[VariantManager] Provider ${providerID} not found`);
|
|
23
|
+
return [{ id: "default" }];
|
|
24
|
+
}
|
|
25
|
+
const model = provider.models[modelID];
|
|
26
|
+
if (!model) {
|
|
27
|
+
logger.warn(`[VariantManager] Model ${modelID} not found in provider ${providerID}`);
|
|
28
|
+
return [{ id: "default" }];
|
|
29
|
+
}
|
|
30
|
+
// Start with default variant (always present)
|
|
31
|
+
const variants = [{ id: "default" }];
|
|
32
|
+
if (model.variants) {
|
|
33
|
+
// Add other variants from API (excluding default if it's already there)
|
|
34
|
+
const apiVariants = Object.entries(model.variants)
|
|
35
|
+
.filter(([id]) => id !== "default")
|
|
36
|
+
.map(([id, info]) => ({
|
|
37
|
+
id,
|
|
38
|
+
disabled: info.disabled,
|
|
39
|
+
}));
|
|
40
|
+
variants.push(...apiVariants);
|
|
41
|
+
logger.debug(`[VariantManager] Found ${variants.length} variants for ${providerID}/${modelID} (including default)`);
|
|
42
|
+
}
|
|
43
|
+
else {
|
|
44
|
+
logger.debug(`[VariantManager] No variants found for ${providerID}/${modelID}, using default only`);
|
|
45
|
+
}
|
|
46
|
+
return variants;
|
|
47
|
+
}
|
|
48
|
+
catch (err) {
|
|
49
|
+
logger.error("[VariantManager] Error fetching variants:", err);
|
|
50
|
+
return [{ id: "default" }];
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Get current variant from settings
|
|
55
|
+
* @returns Current variant ID (defaults to "default")
|
|
56
|
+
*/
|
|
57
|
+
export function getCurrentVariant(scopeKey = "global") {
|
|
58
|
+
const currentModel = getCurrentModel(scopeKey);
|
|
59
|
+
return currentModel?.variant || "default";
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* Set current variant in settings
|
|
63
|
+
* @param variantId Variant ID to set
|
|
64
|
+
*/
|
|
65
|
+
export function setCurrentVariant(variantId, scopeKey = "global") {
|
|
66
|
+
const currentModel = getCurrentModel(scopeKey);
|
|
67
|
+
if (!currentModel) {
|
|
68
|
+
logger.warn("[VariantManager] Cannot set variant: no current model");
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
currentModel.variant = variantId;
|
|
72
|
+
setCurrentModel(currentModel, scopeKey);
|
|
73
|
+
logger.info(`[VariantManager] Variant set to: ${variantId}`);
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* Format variant for button display
|
|
77
|
+
* @param variantId Variant ID (e.g., "default", "low", "high")
|
|
78
|
+
* @returns Formatted string "💠Default", "💠Low", etc.
|
|
79
|
+
*/
|
|
80
|
+
export function formatVariantForButton(variantId) {
|
|
81
|
+
const capitalized = variantId.charAt(0).toUpperCase() + variantId.slice(1);
|
|
82
|
+
return `💡 ${capitalized}`;
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* Format variant for display in messages
|
|
86
|
+
* @param variantId Variant ID
|
|
87
|
+
* @returns Formatted string with capitalized first letter
|
|
88
|
+
*/
|
|
89
|
+
export function formatVariantForDisplay(variantId) {
|
|
90
|
+
return variantId.charAt(0).toUpperCase() + variantId.slice(1);
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* Validate if a model supports a specific variant
|
|
94
|
+
* @param providerID Provider ID
|
|
95
|
+
* @param modelID Model ID
|
|
96
|
+
* @param variantId Variant ID to validate
|
|
97
|
+
* @returns true if variant is supported, false otherwise
|
|
98
|
+
*/
|
|
99
|
+
export async function validateVariantForModel(providerID, modelID, variantId) {
|
|
100
|
+
const variants = await getAvailableVariants(providerID, modelID);
|
|
101
|
+
const found = variants.find((v) => v.id === variantId && !v.disabled);
|
|
102
|
+
return found !== undefined;
|
|
103
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/package.json
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "opencode-telegram-group-topics-bot",
|
|
3
|
+
"version": "0.11.2",
|
|
4
|
+
"description": "Telegram bot client for OpenCode with Telegram forum-topic session lanes.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./dist/index.js",
|
|
7
|
+
"bin": {
|
|
8
|
+
"opencode-telegram-group-topics-bot": "dist/cli.js"
|
|
9
|
+
},
|
|
10
|
+
"exports": {
|
|
11
|
+
".": "./dist/index.js",
|
|
12
|
+
"./cli": "./dist/cli.js"
|
|
13
|
+
},
|
|
14
|
+
"files": [
|
|
15
|
+
"dist",
|
|
16
|
+
"README.md",
|
|
17
|
+
"LICENSE",
|
|
18
|
+
".env.example"
|
|
19
|
+
],
|
|
20
|
+
"engines": {
|
|
21
|
+
"node": ">=20"
|
|
22
|
+
},
|
|
23
|
+
"scripts": {
|
|
24
|
+
"build": "tsc",
|
|
25
|
+
"start": "node dist/index.js",
|
|
26
|
+
"dev": "npm run build && npm start",
|
|
27
|
+
"release:prepare": "node scripts/release-prepare.mjs",
|
|
28
|
+
"release:rc": "node scripts/release-prepare.mjs rc",
|
|
29
|
+
"release:notes:preview": "node scripts/release-notes-preview.mjs",
|
|
30
|
+
"lint": "eslint src --ext .ts --max-warnings=0",
|
|
31
|
+
"format": "prettier --write \"src/**/*.ts\"",
|
|
32
|
+
"test": "vitest run",
|
|
33
|
+
"test:coverage": "vitest run --coverage"
|
|
34
|
+
},
|
|
35
|
+
"repository": {
|
|
36
|
+
"type": "git",
|
|
37
|
+
"url": "git+https://github.com/shanekunz/opencode-telegram-group-topics-bot.git"
|
|
38
|
+
},
|
|
39
|
+
"keywords": [
|
|
40
|
+
"opencode",
|
|
41
|
+
"telegram",
|
|
42
|
+
"telegram-bot",
|
|
43
|
+
"grammy",
|
|
44
|
+
"developer-tools",
|
|
45
|
+
"cli"
|
|
46
|
+
],
|
|
47
|
+
"author": "Shane Kunz and contributors",
|
|
48
|
+
"license": "MIT",
|
|
49
|
+
"bugs": {
|
|
50
|
+
"url": "https://github.com/shanekunz/opencode-telegram-group-topics-bot/issues"
|
|
51
|
+
},
|
|
52
|
+
"homepage": "https://github.com/shanekunz/opencode-telegram-group-topics-bot#readme",
|
|
53
|
+
"dependencies": {
|
|
54
|
+
"@grammyjs/menu": "^1.3.1",
|
|
55
|
+
"@opencode-ai/sdk": "^1.1.21",
|
|
56
|
+
"better-sqlite3": "^12.6.2",
|
|
57
|
+
"dotenv": "^17.2.3",
|
|
58
|
+
"grammy": "^1.39.2",
|
|
59
|
+
"https-proxy-agent": "^7.0.6",
|
|
60
|
+
"socks-proxy-agent": "^8.0.5",
|
|
61
|
+
"telegram-markdown-v2": "^0.0.4"
|
|
62
|
+
},
|
|
63
|
+
"devDependencies": {
|
|
64
|
+
"@types/better-sqlite3": "^7.6.13",
|
|
65
|
+
"@types/node": "^25.0.8",
|
|
66
|
+
"@typescript-eslint/eslint-plugin": "^8.53.0",
|
|
67
|
+
"@typescript-eslint/parser": "^8.53.0",
|
|
68
|
+
"@vitest/coverage-v8": "^3.2.4",
|
|
69
|
+
"eslint": "^8.57.1",
|
|
70
|
+
"eslint-config-prettier": "^10.1.8",
|
|
71
|
+
"prettier": "^3.8.0",
|
|
72
|
+
"tsx": "^4.21.0",
|
|
73
|
+
"typescript": "^5.9.3",
|
|
74
|
+
"vitest": "^3.2.4"
|
|
75
|
+
}
|
|
76
|
+
}
|