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.
Files changed (101) hide show
  1. package/.env.example +74 -0
  2. package/LICENSE +21 -0
  3. package/README.md +305 -0
  4. package/dist/agent/manager.js +60 -0
  5. package/dist/agent/types.js +26 -0
  6. package/dist/app/start-bot-app.js +47 -0
  7. package/dist/bot/commands/abort.js +116 -0
  8. package/dist/bot/commands/commands.js +389 -0
  9. package/dist/bot/commands/constants.js +20 -0
  10. package/dist/bot/commands/definitions.js +25 -0
  11. package/dist/bot/commands/help.js +27 -0
  12. package/dist/bot/commands/models.js +38 -0
  13. package/dist/bot/commands/new.js +247 -0
  14. package/dist/bot/commands/opencode-start.js +85 -0
  15. package/dist/bot/commands/opencode-stop.js +44 -0
  16. package/dist/bot/commands/projects.js +304 -0
  17. package/dist/bot/commands/rename.js +173 -0
  18. package/dist/bot/commands/sessions.js +491 -0
  19. package/dist/bot/commands/start.js +67 -0
  20. package/dist/bot/commands/status.js +138 -0
  21. package/dist/bot/constants.js +49 -0
  22. package/dist/bot/handlers/agent.js +127 -0
  23. package/dist/bot/handlers/context.js +125 -0
  24. package/dist/bot/handlers/document.js +65 -0
  25. package/dist/bot/handlers/inline-menu.js +124 -0
  26. package/dist/bot/handlers/model.js +152 -0
  27. package/dist/bot/handlers/permission.js +281 -0
  28. package/dist/bot/handlers/prompt.js +263 -0
  29. package/dist/bot/handlers/question.js +285 -0
  30. package/dist/bot/handlers/variant.js +147 -0
  31. package/dist/bot/handlers/voice.js +173 -0
  32. package/dist/bot/index.js +945 -0
  33. package/dist/bot/message-patterns.js +4 -0
  34. package/dist/bot/middleware/auth.js +30 -0
  35. package/dist/bot/middleware/interaction-guard.js +80 -0
  36. package/dist/bot/middleware/unknown-command.js +22 -0
  37. package/dist/bot/scope.js +222 -0
  38. package/dist/bot/telegram-constants.js +3 -0
  39. package/dist/bot/telegram-rate-limiter.js +263 -0
  40. package/dist/bot/utils/commands.js +21 -0
  41. package/dist/bot/utils/file-download.js +91 -0
  42. package/dist/bot/utils/keyboard.js +85 -0
  43. package/dist/bot/utils/send-with-markdown-fallback.js +57 -0
  44. package/dist/bot/utils/session-error-filter.js +34 -0
  45. package/dist/bot/utils/topic-link.js +29 -0
  46. package/dist/cli/args.js +98 -0
  47. package/dist/cli.js +80 -0
  48. package/dist/config.js +103 -0
  49. package/dist/i18n/de.js +330 -0
  50. package/dist/i18n/en.js +330 -0
  51. package/dist/i18n/es.js +330 -0
  52. package/dist/i18n/index.js +102 -0
  53. package/dist/i18n/ru.js +330 -0
  54. package/dist/i18n/zh.js +330 -0
  55. package/dist/index.js +28 -0
  56. package/dist/interaction/cleanup.js +24 -0
  57. package/dist/interaction/constants.js +25 -0
  58. package/dist/interaction/guard.js +100 -0
  59. package/dist/interaction/manager.js +113 -0
  60. package/dist/interaction/types.js +1 -0
  61. package/dist/keyboard/manager.js +115 -0
  62. package/dist/keyboard/types.js +1 -0
  63. package/dist/model/capabilities.js +62 -0
  64. package/dist/model/manager.js +257 -0
  65. package/dist/model/types.js +24 -0
  66. package/dist/opencode/client.js +13 -0
  67. package/dist/opencode/events.js +159 -0
  68. package/dist/opencode/prompt-submit-error.js +101 -0
  69. package/dist/permission/manager.js +92 -0
  70. package/dist/permission/types.js +1 -0
  71. package/dist/pinned/manager.js +405 -0
  72. package/dist/pinned/types.js +1 -0
  73. package/dist/process/manager.js +273 -0
  74. package/dist/process/types.js +1 -0
  75. package/dist/project/manager.js +88 -0
  76. package/dist/question/manager.js +186 -0
  77. package/dist/question/types.js +1 -0
  78. package/dist/rename/manager.js +64 -0
  79. package/dist/runtime/bootstrap.js +350 -0
  80. package/dist/runtime/mode.js +74 -0
  81. package/dist/runtime/paths.js +37 -0
  82. package/dist/runtime/process-error-handlers.js +24 -0
  83. package/dist/session/cache-manager.js +455 -0
  84. package/dist/session/manager.js +87 -0
  85. package/dist/settings/manager.js +283 -0
  86. package/dist/stt/client.js +64 -0
  87. package/dist/summary/aggregator.js +625 -0
  88. package/dist/summary/formatter.js +417 -0
  89. package/dist/summary/tool-message-batcher.js +277 -0
  90. package/dist/topic/colors.js +8 -0
  91. package/dist/topic/constants.js +10 -0
  92. package/dist/topic/manager.js +161 -0
  93. package/dist/topic/title-constants.js +2 -0
  94. package/dist/topic/title-format.js +10 -0
  95. package/dist/topic/title-sync.js +17 -0
  96. package/dist/utils/error-format.js +29 -0
  97. package/dist/utils/logger.js +175 -0
  98. package/dist/utils/safe-background-task.js +33 -0
  99. package/dist/variant/manager.js +103 -0
  100. package/dist/variant/types.js +1 -0
  101. 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,2 @@
1
+ export const TOPIC_NAME_MAX_LENGTH = 128;
2
+ export const TOPIC_NAME_TRUNCATION_SUFFIX = "...";
@@ -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
+ }