cowork-os 0.3.21
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/LICENSE +21 -0
- package/README.md +1638 -0
- package/bin/cowork.js +42 -0
- package/build/entitlements.mac.plist +16 -0
- package/build/icon.icns +0 -0
- package/build/icon.png +0 -0
- package/dist/electron/electron/activity/ActivityRepository.js +190 -0
- package/dist/electron/electron/agent/browser/browser-service.js +639 -0
- package/dist/electron/electron/agent/context-manager.js +225 -0
- package/dist/electron/electron/agent/custom-skill-loader.js +566 -0
- package/dist/electron/electron/agent/daemon.js +975 -0
- package/dist/electron/electron/agent/executor.js +3561 -0
- package/dist/electron/electron/agent/llm/anthropic-provider.js +155 -0
- package/dist/electron/electron/agent/llm/bedrock-provider.js +202 -0
- package/dist/electron/electron/agent/llm/gemini-provider.js +375 -0
- package/dist/electron/electron/agent/llm/index.js +34 -0
- package/dist/electron/electron/agent/llm/ollama-provider.js +263 -0
- package/dist/electron/electron/agent/llm/openai-oauth.js +101 -0
- package/dist/electron/electron/agent/llm/openai-provider.js +657 -0
- package/dist/electron/electron/agent/llm/openrouter-provider.js +232 -0
- package/dist/electron/electron/agent/llm/pricing.js +160 -0
- package/dist/electron/electron/agent/llm/provider-factory.js +880 -0
- package/dist/electron/electron/agent/llm/types.js +178 -0
- package/dist/electron/electron/agent/queue-manager.js +378 -0
- package/dist/electron/electron/agent/sandbox/docker-sandbox.js +402 -0
- package/dist/electron/electron/agent/sandbox/macos-sandbox.js +407 -0
- package/dist/electron/electron/agent/sandbox/runner.js +410 -0
- package/dist/electron/electron/agent/sandbox/sandbox-factory.js +228 -0
- package/dist/electron/electron/agent/sandbox/security-utils.js +258 -0
- package/dist/electron/electron/agent/search/brave-provider.js +119 -0
- package/dist/electron/electron/agent/search/google-provider.js +100 -0
- package/dist/electron/electron/agent/search/index.js +28 -0
- package/dist/electron/electron/agent/search/provider-factory.js +395 -0
- package/dist/electron/electron/agent/search/serpapi-provider.js +112 -0
- package/dist/electron/electron/agent/search/tavily-provider.js +90 -0
- package/dist/electron/electron/agent/search/types.js +40 -0
- package/dist/electron/electron/agent/security/index.js +12 -0
- package/dist/electron/electron/agent/security/input-sanitizer.js +303 -0
- package/dist/electron/electron/agent/security/output-filter.js +217 -0
- package/dist/electron/electron/agent/skill-eligibility.js +281 -0
- package/dist/electron/electron/agent/skill-registry.js +396 -0
- package/dist/electron/electron/agent/skills/document.js +878 -0
- package/dist/electron/electron/agent/skills/image-generator.js +225 -0
- package/dist/electron/electron/agent/skills/organizer.js +141 -0
- package/dist/electron/electron/agent/skills/presentation.js +367 -0
- package/dist/electron/electron/agent/skills/spreadsheet.js +165 -0
- package/dist/electron/electron/agent/tools/browser-tools.js +523 -0
- package/dist/electron/electron/agent/tools/builtin-settings.js +384 -0
- package/dist/electron/electron/agent/tools/canvas-tools.js +530 -0
- package/dist/electron/electron/agent/tools/cron-tools.js +577 -0
- package/dist/electron/electron/agent/tools/edit-tools.js +194 -0
- package/dist/electron/electron/agent/tools/file-tools.js +719 -0
- package/dist/electron/electron/agent/tools/glob-tools.js +283 -0
- package/dist/electron/electron/agent/tools/grep-tools.js +387 -0
- package/dist/electron/electron/agent/tools/image-tools.js +111 -0
- package/dist/electron/electron/agent/tools/mention-tools.js +282 -0
- package/dist/electron/electron/agent/tools/node-tools.js +476 -0
- package/dist/electron/electron/agent/tools/registry.js +2719 -0
- package/dist/electron/electron/agent/tools/search-tools.js +91 -0
- package/dist/electron/electron/agent/tools/shell-tools.js +574 -0
- package/dist/electron/electron/agent/tools/skill-tools.js +274 -0
- package/dist/electron/electron/agent/tools/system-tools.js +578 -0
- package/dist/electron/electron/agent/tools/web-fetch-tools.js +444 -0
- package/dist/electron/electron/agent/tools/x-tools.js +264 -0
- package/dist/electron/electron/agents/AgentRoleRepository.js +420 -0
- package/dist/electron/electron/agents/HeartbeatService.js +356 -0
- package/dist/electron/electron/agents/MentionRepository.js +197 -0
- package/dist/electron/electron/agents/TaskSubscriptionRepository.js +168 -0
- package/dist/electron/electron/agents/WorkingStateRepository.js +229 -0
- package/dist/electron/electron/canvas/canvas-manager.js +714 -0
- package/dist/electron/electron/canvas/canvas-preload.js +53 -0
- package/dist/electron/electron/canvas/canvas-protocol.js +195 -0
- package/dist/electron/electron/canvas/canvas-store.js +174 -0
- package/dist/electron/electron/canvas/index.js +13 -0
- package/dist/electron/electron/control-plane/client.js +364 -0
- package/dist/electron/electron/control-plane/handlers.js +572 -0
- package/dist/electron/electron/control-plane/index.js +41 -0
- package/dist/electron/electron/control-plane/node-manager.js +264 -0
- package/dist/electron/electron/control-plane/protocol.js +194 -0
- package/dist/electron/electron/control-plane/remote-client.js +437 -0
- package/dist/electron/electron/control-plane/server.js +640 -0
- package/dist/electron/electron/control-plane/settings.js +369 -0
- package/dist/electron/electron/control-plane/ssh-tunnel.js +549 -0
- package/dist/electron/electron/cron/index.js +30 -0
- package/dist/electron/electron/cron/schedule.js +190 -0
- package/dist/electron/electron/cron/service.js +614 -0
- package/dist/electron/electron/cron/store.js +155 -0
- package/dist/electron/electron/cron/types.js +82 -0
- package/dist/electron/electron/cron/webhook.js +258 -0
- package/dist/electron/electron/database/SecureSettingsRepository.js +444 -0
- package/dist/electron/electron/database/TaskLabelRepository.js +120 -0
- package/dist/electron/electron/database/repositories.js +1781 -0
- package/dist/electron/electron/database/schema.js +978 -0
- package/dist/electron/electron/extensions/index.js +33 -0
- package/dist/electron/electron/extensions/loader.js +313 -0
- package/dist/electron/electron/extensions/registry.js +485 -0
- package/dist/electron/electron/extensions/types.js +11 -0
- package/dist/electron/electron/gateway/channel-registry.js +1102 -0
- package/dist/electron/electron/gateway/channels/bluebubbles-client.js +479 -0
- package/dist/electron/electron/gateway/channels/bluebubbles.js +432 -0
- package/dist/electron/electron/gateway/channels/discord.js +975 -0
- package/dist/electron/electron/gateway/channels/email-client.js +593 -0
- package/dist/electron/electron/gateway/channels/email.js +443 -0
- package/dist/electron/electron/gateway/channels/google-chat.js +631 -0
- package/dist/electron/electron/gateway/channels/imessage-client.js +363 -0
- package/dist/electron/electron/gateway/channels/imessage.js +465 -0
- package/dist/electron/electron/gateway/channels/index.js +36 -0
- package/dist/electron/electron/gateway/channels/line-client.js +470 -0
- package/dist/electron/electron/gateway/channels/line.js +479 -0
- package/dist/electron/electron/gateway/channels/matrix-client.js +432 -0
- package/dist/electron/electron/gateway/channels/matrix.js +592 -0
- package/dist/electron/electron/gateway/channels/mattermost-client.js +394 -0
- package/dist/electron/electron/gateway/channels/mattermost.js +496 -0
- package/dist/electron/electron/gateway/channels/signal-client.js +500 -0
- package/dist/electron/electron/gateway/channels/signal.js +582 -0
- package/dist/electron/electron/gateway/channels/slack.js +415 -0
- package/dist/electron/electron/gateway/channels/teams.js +596 -0
- package/dist/electron/electron/gateway/channels/telegram.js +1390 -0
- package/dist/electron/electron/gateway/channels/twitch-client.js +502 -0
- package/dist/electron/electron/gateway/channels/twitch.js +396 -0
- package/dist/electron/electron/gateway/channels/types.js +8 -0
- package/dist/electron/electron/gateway/channels/whatsapp.js +953 -0
- package/dist/electron/electron/gateway/context-policy.js +268 -0
- package/dist/electron/electron/gateway/index.js +1063 -0
- package/dist/electron/electron/gateway/infrastructure.js +496 -0
- package/dist/electron/electron/gateway/router.js +2700 -0
- package/dist/electron/electron/gateway/security.js +375 -0
- package/dist/electron/electron/gateway/session.js +115 -0
- package/dist/electron/electron/gateway/tunnel.js +503 -0
- package/dist/electron/electron/guardrails/guardrail-manager.js +348 -0
- package/dist/electron/electron/hooks/gmail-watcher.js +300 -0
- package/dist/electron/electron/hooks/index.js +46 -0
- package/dist/electron/electron/hooks/mappings.js +381 -0
- package/dist/electron/electron/hooks/server.js +480 -0
- package/dist/electron/electron/hooks/settings.js +447 -0
- package/dist/electron/electron/hooks/types.js +41 -0
- package/dist/electron/electron/ipc/canvas-handlers.js +158 -0
- package/dist/electron/electron/ipc/handlers.js +3138 -0
- package/dist/electron/electron/ipc/mission-control-handlers.js +141 -0
- package/dist/electron/electron/main.js +448 -0
- package/dist/electron/electron/mcp/client/MCPClientManager.js +330 -0
- package/dist/electron/electron/mcp/client/MCPServerConnection.js +437 -0
- package/dist/electron/electron/mcp/client/transports/SSETransport.js +304 -0
- package/dist/electron/electron/mcp/client/transports/StdioTransport.js +307 -0
- package/dist/electron/electron/mcp/client/transports/WebSocketTransport.js +329 -0
- package/dist/electron/electron/mcp/host/MCPHostServer.js +354 -0
- package/dist/electron/electron/mcp/host/ToolAdapter.js +100 -0
- package/dist/electron/electron/mcp/registry/MCPRegistryManager.js +497 -0
- package/dist/electron/electron/mcp/settings.js +446 -0
- package/dist/electron/electron/mcp/types.js +59 -0
- package/dist/electron/electron/memory/MemoryService.js +435 -0
- package/dist/electron/electron/notifications/index.js +17 -0
- package/dist/electron/electron/notifications/service.js +118 -0
- package/dist/electron/electron/notifications/store.js +144 -0
- package/dist/electron/electron/preload.js +842 -0
- package/dist/electron/electron/reports/StandupReportService.js +272 -0
- package/dist/electron/electron/security/concurrency.js +293 -0
- package/dist/electron/electron/security/index.js +15 -0
- package/dist/electron/electron/security/policy-manager.js +435 -0
- package/dist/electron/electron/settings/appearance-manager.js +193 -0
- package/dist/electron/electron/settings/personality-manager.js +724 -0
- package/dist/electron/electron/settings/x-manager.js +58 -0
- package/dist/electron/electron/tailscale/exposure.js +188 -0
- package/dist/electron/electron/tailscale/index.js +28 -0
- package/dist/electron/electron/tailscale/settings.js +205 -0
- package/dist/electron/electron/tailscale/tailscale.js +355 -0
- package/dist/electron/electron/tray/QuickInputWindow.js +568 -0
- package/dist/electron/electron/tray/TrayManager.js +895 -0
- package/dist/electron/electron/tray/index.js +9 -0
- package/dist/electron/electron/updater/index.js +6 -0
- package/dist/electron/electron/updater/update-manager.js +418 -0
- package/dist/electron/electron/utils/env-migration.js +209 -0
- package/dist/electron/electron/utils/process.js +102 -0
- package/dist/electron/electron/utils/rate-limiter.js +104 -0
- package/dist/electron/electron/utils/validation.js +419 -0
- package/dist/electron/electron/utils/x-cli.js +177 -0
- package/dist/electron/electron/voice/VoiceService.js +507 -0
- package/dist/electron/electron/voice/index.js +14 -0
- package/dist/electron/electron/voice/voice-settings-manager.js +359 -0
- package/dist/electron/shared/channelMessages.js +170 -0
- package/dist/electron/shared/types.js +1185 -0
- package/package.json +159 -0
- package/resources/skills/1password.json +10 -0
- package/resources/skills/add-documentation.json +31 -0
- package/resources/skills/analyze-csv.json +17 -0
- package/resources/skills/apple-notes.json +10 -0
- package/resources/skills/apple-reminders.json +10 -0
- package/resources/skills/auto-commenter.json +10 -0
- package/resources/skills/bear-notes.json +10 -0
- package/resources/skills/bird.json +35 -0
- package/resources/skills/blogwatcher.json +10 -0
- package/resources/skills/blucli.json +10 -0
- package/resources/skills/bluebubbles.json +10 -0
- package/resources/skills/camsnap.json +10 -0
- package/resources/skills/clean-imports.json +18 -0
- package/resources/skills/code-review.json +18 -0
- package/resources/skills/coding-agent.json +10 -0
- package/resources/skills/compare-files.json +23 -0
- package/resources/skills/convert-code.json +34 -0
- package/resources/skills/create-changelog.json +24 -0
- package/resources/skills/debug-error.json +17 -0
- package/resources/skills/dependency-check.json +10 -0
- package/resources/skills/discord.json +10 -0
- package/resources/skills/eightctl.json +10 -0
- package/resources/skills/explain-code.json +29 -0
- package/resources/skills/extract-todos.json +18 -0
- package/resources/skills/food-order.json +10 -0
- package/resources/skills/gemini.json +10 -0
- package/resources/skills/generate-readme.json +10 -0
- package/resources/skills/gifgrep.json +10 -0
- package/resources/skills/git-commit.json +10 -0
- package/resources/skills/github.json +10 -0
- package/resources/skills/gog.json +10 -0
- package/resources/skills/goplaces.json +10 -0
- package/resources/skills/himalaya.json +10 -0
- package/resources/skills/imsg.json +10 -0
- package/resources/skills/karpathy-guidelines.json +12 -0
- package/resources/skills/last30days.json +26 -0
- package/resources/skills/local-places.json +10 -0
- package/resources/skills/mcporter.json +10 -0
- package/resources/skills/model-usage.json +10 -0
- package/resources/skills/nano-banana-pro.json +10 -0
- package/resources/skills/nano-pdf.json +10 -0
- package/resources/skills/notion.json +10 -0
- package/resources/skills/obsidian.json +10 -0
- package/resources/skills/openai-image-gen.json +10 -0
- package/resources/skills/openai-whisper-api.json +10 -0
- package/resources/skills/openai-whisper.json +10 -0
- package/resources/skills/openhue.json +10 -0
- package/resources/skills/oracle.json +10 -0
- package/resources/skills/ordercli.json +10 -0
- package/resources/skills/peekaboo.json +10 -0
- package/resources/skills/project-structure.json +10 -0
- package/resources/skills/proofread.json +17 -0
- package/resources/skills/refactor-code.json +31 -0
- package/resources/skills/rename-symbol.json +23 -0
- package/resources/skills/sag.json +10 -0
- package/resources/skills/security-audit.json +18 -0
- package/resources/skills/session-logs.json +10 -0
- package/resources/skills/sherpa-onnx-tts.json +10 -0
- package/resources/skills/skill-creator.json +15 -0
- package/resources/skills/skill-hub.json +29 -0
- package/resources/skills/slack.json +10 -0
- package/resources/skills/songsee.json +10 -0
- package/resources/skills/sonoscli.json +10 -0
- package/resources/skills/spotify-player.json +10 -0
- package/resources/skills/startup-cfo.json +55 -0
- package/resources/skills/summarize-folder.json +18 -0
- package/resources/skills/summarize.json +10 -0
- package/resources/skills/things-mac.json +10 -0
- package/resources/skills/tmux.json +10 -0
- package/resources/skills/translate.json +36 -0
- package/resources/skills/trello.json +10 -0
- package/resources/skills/video-frames.json +10 -0
- package/resources/skills/voice-call.json +10 -0
- package/resources/skills/wacli.json +10 -0
- package/resources/skills/weather.json +10 -0
- package/resources/skills/write-tests.json +31 -0
- package/src/electron/activity/ActivityRepository.ts +238 -0
- package/src/electron/agent/browser/browser-service.ts +721 -0
- package/src/electron/agent/context-manager.ts +257 -0
- package/src/electron/agent/custom-skill-loader.ts +634 -0
- package/src/electron/agent/daemon.ts +1097 -0
- package/src/electron/agent/executor.ts +4017 -0
- package/src/electron/agent/llm/anthropic-provider.ts +175 -0
- package/src/electron/agent/llm/bedrock-provider.ts +236 -0
- package/src/electron/agent/llm/gemini-provider.ts +422 -0
- package/src/electron/agent/llm/index.ts +9 -0
- package/src/electron/agent/llm/ollama-provider.ts +347 -0
- package/src/electron/agent/llm/openai-oauth.ts +127 -0
- package/src/electron/agent/llm/openai-provider.ts +686 -0
- package/src/electron/agent/llm/openrouter-provider.ts +273 -0
- package/src/electron/agent/llm/pricing.ts +180 -0
- package/src/electron/agent/llm/provider-factory.ts +971 -0
- package/src/electron/agent/llm/types.ts +291 -0
- package/src/electron/agent/queue-manager.ts +408 -0
- package/src/electron/agent/sandbox/docker-sandbox.ts +453 -0
- package/src/electron/agent/sandbox/macos-sandbox.ts +426 -0
- package/src/electron/agent/sandbox/runner.ts +453 -0
- package/src/electron/agent/sandbox/sandbox-factory.ts +337 -0
- package/src/electron/agent/sandbox/security-utils.ts +251 -0
- package/src/electron/agent/search/brave-provider.ts +141 -0
- package/src/electron/agent/search/google-provider.ts +131 -0
- package/src/electron/agent/search/index.ts +6 -0
- package/src/electron/agent/search/provider-factory.ts +450 -0
- package/src/electron/agent/search/serpapi-provider.ts +138 -0
- package/src/electron/agent/search/tavily-provider.ts +108 -0
- package/src/electron/agent/search/types.ts +118 -0
- package/src/electron/agent/security/index.ts +20 -0
- package/src/electron/agent/security/input-sanitizer.ts +380 -0
- package/src/electron/agent/security/output-filter.ts +259 -0
- package/src/electron/agent/skill-eligibility.ts +334 -0
- package/src/electron/agent/skill-registry.ts +457 -0
- package/src/electron/agent/skills/document.ts +1070 -0
- package/src/electron/agent/skills/image-generator.ts +272 -0
- package/src/electron/agent/skills/organizer.ts +131 -0
- package/src/electron/agent/skills/presentation.ts +418 -0
- package/src/electron/agent/skills/spreadsheet.ts +166 -0
- package/src/electron/agent/tools/browser-tools.ts +546 -0
- package/src/electron/agent/tools/builtin-settings.ts +422 -0
- package/src/electron/agent/tools/canvas-tools.ts +572 -0
- package/src/electron/agent/tools/cron-tools.ts +723 -0
- package/src/electron/agent/tools/edit-tools.ts +196 -0
- package/src/electron/agent/tools/file-tools.ts +811 -0
- package/src/electron/agent/tools/glob-tools.ts +303 -0
- package/src/electron/agent/tools/grep-tools.ts +432 -0
- package/src/electron/agent/tools/image-tools.ts +126 -0
- package/src/electron/agent/tools/mention-tools.ts +371 -0
- package/src/electron/agent/tools/node-tools.ts +550 -0
- package/src/electron/agent/tools/registry.ts +3052 -0
- package/src/electron/agent/tools/search-tools.ts +111 -0
- package/src/electron/agent/tools/shell-tools.ts +651 -0
- package/src/electron/agent/tools/skill-tools.ts +340 -0
- package/src/electron/agent/tools/system-tools.ts +665 -0
- package/src/electron/agent/tools/web-fetch-tools.ts +528 -0
- package/src/electron/agent/tools/x-tools.ts +267 -0
- package/src/electron/agents/AgentRoleRepository.ts +557 -0
- package/src/electron/agents/HeartbeatService.ts +469 -0
- package/src/electron/agents/MentionRepository.ts +242 -0
- package/src/electron/agents/TaskSubscriptionRepository.ts +231 -0
- package/src/electron/agents/WorkingStateRepository.ts +278 -0
- package/src/electron/canvas/canvas-manager.ts +818 -0
- package/src/electron/canvas/canvas-preload.ts +102 -0
- package/src/electron/canvas/canvas-protocol.ts +174 -0
- package/src/electron/canvas/canvas-store.ts +200 -0
- package/src/electron/canvas/index.ts +8 -0
- package/src/electron/control-plane/client.ts +527 -0
- package/src/electron/control-plane/handlers.ts +723 -0
- package/src/electron/control-plane/index.ts +51 -0
- package/src/electron/control-plane/node-manager.ts +322 -0
- package/src/electron/control-plane/protocol.ts +269 -0
- package/src/electron/control-plane/remote-client.ts +517 -0
- package/src/electron/control-plane/server.ts +853 -0
- package/src/electron/control-plane/settings.ts +401 -0
- package/src/electron/control-plane/ssh-tunnel.ts +624 -0
- package/src/electron/cron/index.ts +9 -0
- package/src/electron/cron/schedule.ts +217 -0
- package/src/electron/cron/service.ts +743 -0
- package/src/electron/cron/store.ts +165 -0
- package/src/electron/cron/types.ts +291 -0
- package/src/electron/cron/webhook.ts +303 -0
- package/src/electron/database/SecureSettingsRepository.ts +514 -0
- package/src/electron/database/TaskLabelRepository.ts +148 -0
- package/src/electron/database/repositories.ts +2397 -0
- package/src/electron/database/schema.ts +1017 -0
- package/src/electron/extensions/index.ts +18 -0
- package/src/electron/extensions/loader.ts +336 -0
- package/src/electron/extensions/registry.ts +546 -0
- package/src/electron/extensions/types.ts +372 -0
- package/src/electron/gateway/channel-registry.ts +1267 -0
- package/src/electron/gateway/channels/bluebubbles-client.ts +641 -0
- package/src/electron/gateway/channels/bluebubbles.ts +509 -0
- package/src/electron/gateway/channels/discord.ts +1150 -0
- package/src/electron/gateway/channels/email-client.ts +708 -0
- package/src/electron/gateway/channels/email.ts +516 -0
- package/src/electron/gateway/channels/google-chat.ts +760 -0
- package/src/electron/gateway/channels/imessage-client.ts +473 -0
- package/src/electron/gateway/channels/imessage.ts +520 -0
- package/src/electron/gateway/channels/index.ts +21 -0
- package/src/electron/gateway/channels/line-client.ts +598 -0
- package/src/electron/gateway/channels/line.ts +559 -0
- package/src/electron/gateway/channels/matrix-client.ts +632 -0
- package/src/electron/gateway/channels/matrix.ts +655 -0
- package/src/electron/gateway/channels/mattermost-client.ts +526 -0
- package/src/electron/gateway/channels/mattermost.ts +550 -0
- package/src/electron/gateway/channels/signal-client.ts +722 -0
- package/src/electron/gateway/channels/signal.ts +666 -0
- package/src/electron/gateway/channels/slack.ts +458 -0
- package/src/electron/gateway/channels/teams.ts +681 -0
- package/src/electron/gateway/channels/telegram.ts +1727 -0
- package/src/electron/gateway/channels/twitch-client.ts +665 -0
- package/src/electron/gateway/channels/twitch.ts +468 -0
- package/src/electron/gateway/channels/types.ts +1002 -0
- package/src/electron/gateway/channels/whatsapp.ts +1101 -0
- package/src/electron/gateway/context-policy.ts +382 -0
- package/src/electron/gateway/index.ts +1274 -0
- package/src/electron/gateway/infrastructure.ts +645 -0
- package/src/electron/gateway/router.ts +3206 -0
- package/src/electron/gateway/security.ts +422 -0
- package/src/electron/gateway/session.ts +144 -0
- package/src/electron/gateway/tunnel.ts +626 -0
- package/src/electron/guardrails/guardrail-manager.ts +380 -0
- package/src/electron/hooks/gmail-watcher.ts +355 -0
- package/src/electron/hooks/index.ts +30 -0
- package/src/electron/hooks/mappings.ts +404 -0
- package/src/electron/hooks/server.ts +574 -0
- package/src/electron/hooks/settings.ts +466 -0
- package/src/electron/hooks/types.ts +245 -0
- package/src/electron/ipc/canvas-handlers.ts +223 -0
- package/src/electron/ipc/handlers.ts +3661 -0
- package/src/electron/ipc/mission-control-handlers.ts +182 -0
- package/src/electron/main.ts +496 -0
- package/src/electron/mcp/client/MCPClientManager.ts +406 -0
- package/src/electron/mcp/client/MCPServerConnection.ts +514 -0
- package/src/electron/mcp/client/transports/SSETransport.ts +360 -0
- package/src/electron/mcp/client/transports/StdioTransport.ts +355 -0
- package/src/electron/mcp/client/transports/WebSocketTransport.ts +384 -0
- package/src/electron/mcp/host/MCPHostServer.ts +388 -0
- package/src/electron/mcp/host/ToolAdapter.ts +140 -0
- package/src/electron/mcp/registry/MCPRegistryManager.ts +565 -0
- package/src/electron/mcp/settings.ts +468 -0
- package/src/electron/mcp/types.ts +371 -0
- package/src/electron/memory/MemoryService.ts +523 -0
- package/src/electron/notifications/index.ts +16 -0
- package/src/electron/notifications/service.ts +161 -0
- package/src/electron/notifications/store.ts +163 -0
- package/src/electron/preload.ts +2845 -0
- package/src/electron/reports/StandupReportService.ts +356 -0
- package/src/electron/security/concurrency.ts +333 -0
- package/src/electron/security/index.ts +17 -0
- package/src/electron/security/policy-manager.ts +539 -0
- package/src/electron/settings/appearance-manager.ts +182 -0
- package/src/electron/settings/personality-manager.ts +800 -0
- package/src/electron/settings/x-manager.ts +62 -0
- package/src/electron/tailscale/exposure.ts +262 -0
- package/src/electron/tailscale/index.ts +34 -0
- package/src/electron/tailscale/settings.ts +218 -0
- package/src/electron/tailscale/tailscale.ts +379 -0
- package/src/electron/tray/QuickInputWindow.ts +609 -0
- package/src/electron/tray/TrayManager.ts +1005 -0
- package/src/electron/tray/index.ts +6 -0
- package/src/electron/updater/index.ts +1 -0
- package/src/electron/updater/update-manager.ts +447 -0
- package/src/electron/utils/env-migration.ts +203 -0
- package/src/electron/utils/process.ts +124 -0
- package/src/electron/utils/rate-limiter.ts +130 -0
- package/src/electron/utils/validation.ts +493 -0
- package/src/electron/utils/x-cli.ts +198 -0
- package/src/electron/voice/VoiceService.ts +583 -0
- package/src/electron/voice/index.ts +9 -0
- package/src/electron/voice/voice-settings-manager.ts +403 -0
- package/src/renderer/App.tsx +775 -0
- package/src/renderer/components/ActivityFeed.tsx +407 -0
- package/src/renderer/components/ActivityFeedItem.tsx +285 -0
- package/src/renderer/components/AgentRoleCard.tsx +343 -0
- package/src/renderer/components/AgentRoleEditor.tsx +805 -0
- package/src/renderer/components/AgentSquadSettings.tsx +295 -0
- package/src/renderer/components/AgentWorkingStatePanel.tsx +411 -0
- package/src/renderer/components/AppearanceSettings.tsx +122 -0
- package/src/renderer/components/ApprovalDialog.tsx +100 -0
- package/src/renderer/components/BlueBubblesSettings.tsx +505 -0
- package/src/renderer/components/BuiltinToolsSettings.tsx +307 -0
- package/src/renderer/components/CanvasPreview.tsx +1189 -0
- package/src/renderer/components/CommandOutput.tsx +202 -0
- package/src/renderer/components/ContextPolicySettings.tsx +523 -0
- package/src/renderer/components/ControlPlaneSettings.tsx +1134 -0
- package/src/renderer/components/DisclaimerModal.tsx +124 -0
- package/src/renderer/components/DiscordSettings.tsx +436 -0
- package/src/renderer/components/EmailSettings.tsx +606 -0
- package/src/renderer/components/ExtensionsSettings.tsx +542 -0
- package/src/renderer/components/FileViewer.tsx +224 -0
- package/src/renderer/components/GoogleChatSettings.tsx +535 -0
- package/src/renderer/components/GuardrailSettings.tsx +487 -0
- package/src/renderer/components/HooksSettings.tsx +581 -0
- package/src/renderer/components/ImessageSettings.tsx +484 -0
- package/src/renderer/components/LineSettings.tsx +483 -0
- package/src/renderer/components/MCPRegistryBrowser.tsx +386 -0
- package/src/renderer/components/MCPSettings.tsx +943 -0
- package/src/renderer/components/MainContent.tsx +2433 -0
- package/src/renderer/components/MatrixSettings.tsx +510 -0
- package/src/renderer/components/MattermostSettings.tsx +473 -0
- package/src/renderer/components/MemorySettings.tsx +247 -0
- package/src/renderer/components/MentionBadge.tsx +87 -0
- package/src/renderer/components/MentionInput.tsx +409 -0
- package/src/renderer/components/MentionList.tsx +476 -0
- package/src/renderer/components/MissionControlPanel.tsx +1995 -0
- package/src/renderer/components/NodesSettings.tsx +316 -0
- package/src/renderer/components/NotificationPanel.tsx +481 -0
- package/src/renderer/components/Onboarding/AwakeningOrb.tsx +44 -0
- package/src/renderer/components/Onboarding/Onboarding.tsx +443 -0
- package/src/renderer/components/Onboarding/TypewriterText.tsx +102 -0
- package/src/renderer/components/Onboarding/index.ts +3 -0
- package/src/renderer/components/OnboardingModal.tsx +698 -0
- package/src/renderer/components/PairingCodeDisplay.tsx +324 -0
- package/src/renderer/components/PersonalitySettings.tsx +597 -0
- package/src/renderer/components/QueueSettings.tsx +119 -0
- package/src/renderer/components/QuickTaskFAB.tsx +71 -0
- package/src/renderer/components/RightPanel.tsx +413 -0
- package/src/renderer/components/ScheduledTasksSettings.tsx +1328 -0
- package/src/renderer/components/SearchSettings.tsx +328 -0
- package/src/renderer/components/Settings.tsx +1504 -0
- package/src/renderer/components/Sidebar.tsx +344 -0
- package/src/renderer/components/SignalSettings.tsx +673 -0
- package/src/renderer/components/SkillHubBrowser.tsx +458 -0
- package/src/renderer/components/SkillParameterModal.tsx +185 -0
- package/src/renderer/components/SkillsSettings.tsx +451 -0
- package/src/renderer/components/SlackSettings.tsx +442 -0
- package/src/renderer/components/StandupReportViewer.tsx +614 -0
- package/src/renderer/components/TaskBoard.tsx +498 -0
- package/src/renderer/components/TaskBoardCard.tsx +357 -0
- package/src/renderer/components/TaskBoardColumn.tsx +211 -0
- package/src/renderer/components/TaskLabelManager.tsx +472 -0
- package/src/renderer/components/TaskQueuePanel.tsx +144 -0
- package/src/renderer/components/TaskQuickActions.tsx +492 -0
- package/src/renderer/components/TaskTimeline.tsx +216 -0
- package/src/renderer/components/TaskView.tsx +162 -0
- package/src/renderer/components/TeamsSettings.tsx +518 -0
- package/src/renderer/components/TelegramSettings.tsx +421 -0
- package/src/renderer/components/Toast.tsx +76 -0
- package/src/renderer/components/TraySettings.tsx +189 -0
- package/src/renderer/components/TwitchSettings.tsx +511 -0
- package/src/renderer/components/UpdateSettings.tsx +295 -0
- package/src/renderer/components/VoiceIndicator.tsx +270 -0
- package/src/renderer/components/VoiceSettings.tsx +867 -0
- package/src/renderer/components/WhatsAppSettings.tsx +721 -0
- package/src/renderer/components/WorkingStateEditor.tsx +309 -0
- package/src/renderer/components/WorkingStateHistory.tsx +481 -0
- package/src/renderer/components/WorkspaceSelector.tsx +150 -0
- package/src/renderer/components/XSettings.tsx +311 -0
- package/src/renderer/global.d.ts +9 -0
- package/src/renderer/hooks/useAgentContext.ts +153 -0
- package/src/renderer/hooks/useOnboardingFlow.ts +548 -0
- package/src/renderer/hooks/useVoiceInput.ts +268 -0
- package/src/renderer/index.html +12 -0
- package/src/renderer/main.tsx +10 -0
- package/src/renderer/public/cowork-os-logo.png +0 -0
- package/src/renderer/quick-input.html +164 -0
- package/src/renderer/styles/index.css +14504 -0
- package/src/renderer/utils/agentMessages.ts +749 -0
- package/src/renderer/utils/voice-directives.ts +169 -0
- package/src/shared/channelMessages.ts +213 -0
- package/src/shared/types.ts +3608 -0
- package/tsconfig.electron.json +26 -0
- package/tsconfig.json +26 -0
- package/tsconfig.node.json +10 -0
- package/vite.config.ts +23 -0
|
@@ -0,0 +1,1727 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Telegram Channel Adapter
|
|
3
|
+
*
|
|
4
|
+
* Implements the ChannelAdapter interface using grammY for Telegram Bot API.
|
|
5
|
+
* Supports both polling and webhook modes.
|
|
6
|
+
*
|
|
7
|
+
* Features:
|
|
8
|
+
* - API throttling to prevent rate limits
|
|
9
|
+
* - Message deduplication to prevent double processing
|
|
10
|
+
* - Text fragment assembly for split long messages
|
|
11
|
+
* - ACK reactions while processing
|
|
12
|
+
* - Draft streaming for real-time response preview
|
|
13
|
+
* - Sequential message processing to prevent race conditions
|
|
14
|
+
* - Connection conflict detection (409 errors)
|
|
15
|
+
* - Exponential backoff with jitter for error recovery
|
|
16
|
+
* - Health check endpoint for webhook mode
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { Bot, Context, webhookCallback, InputFile, GrammyError, HttpError, InlineKeyboard } from 'grammy';
|
|
20
|
+
import { sequentialize } from '@grammyjs/runner';
|
|
21
|
+
import { apiThrottler } from '@grammyjs/transformer-throttler';
|
|
22
|
+
import * as fs from 'fs';
|
|
23
|
+
import * as path from 'path';
|
|
24
|
+
import * as http from 'http';
|
|
25
|
+
import {
|
|
26
|
+
ChannelAdapter,
|
|
27
|
+
ChannelStatus,
|
|
28
|
+
IncomingMessage,
|
|
29
|
+
OutgoingMessage,
|
|
30
|
+
MessageHandler,
|
|
31
|
+
ErrorHandler,
|
|
32
|
+
StatusHandler,
|
|
33
|
+
ChannelInfo,
|
|
34
|
+
TelegramConfig,
|
|
35
|
+
MessageAttachment,
|
|
36
|
+
CallbackQuery,
|
|
37
|
+
CallbackQueryHandler,
|
|
38
|
+
InlineKeyboardButton,
|
|
39
|
+
Poll,
|
|
40
|
+
ReplyKeyboard,
|
|
41
|
+
} from './types';
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Exponential backoff configuration
|
|
45
|
+
*/
|
|
46
|
+
export interface BackoffConfig {
|
|
47
|
+
/** Initial delay in ms (default: 2000) */
|
|
48
|
+
initialDelay?: number;
|
|
49
|
+
/** Maximum delay in ms (default: 30000) */
|
|
50
|
+
maxDelay?: number;
|
|
51
|
+
/** Backoff multiplier (default: 1.8) */
|
|
52
|
+
multiplier?: number;
|
|
53
|
+
/** Jitter percentage 0-1 (default: 0.25) */
|
|
54
|
+
jitter?: number;
|
|
55
|
+
/** Maximum retry attempts before giving up (default: 10) */
|
|
56
|
+
maxAttempts?: number;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Webhook server configuration
|
|
61
|
+
*/
|
|
62
|
+
export interface WebhookServerConfig {
|
|
63
|
+
/** Port to listen on */
|
|
64
|
+
port: number;
|
|
65
|
+
/** Host to bind to (default: '0.0.0.0') */
|
|
66
|
+
host?: string;
|
|
67
|
+
/** Secret token for webhook validation */
|
|
68
|
+
secretToken?: string;
|
|
69
|
+
/** Path for webhook endpoint (default: '/webhook') */
|
|
70
|
+
webhookPath?: string;
|
|
71
|
+
/** Path for health check (default: '/healthz') */
|
|
72
|
+
healthPath?: string;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Extended Telegram configuration with new features
|
|
77
|
+
*/
|
|
78
|
+
export interface TelegramAdapterConfig extends TelegramConfig {
|
|
79
|
+
/** Enable ACK reaction (👀) while processing messages */
|
|
80
|
+
ackReactionEnabled?: boolean;
|
|
81
|
+
/** Enable draft streaming for real-time response preview */
|
|
82
|
+
draftStreamingEnabled?: boolean;
|
|
83
|
+
/** Text fragment assembly timeout in ms (default: 1500) */
|
|
84
|
+
fragmentAssemblyTimeout?: number;
|
|
85
|
+
/** Enable message deduplication (default: true) */
|
|
86
|
+
deduplicationEnabled?: boolean;
|
|
87
|
+
/** Enable sequential message processing (default: true) */
|
|
88
|
+
sequentialProcessingEnabled?: boolean;
|
|
89
|
+
/** Exponential backoff configuration */
|
|
90
|
+
backoff?: BackoffConfig;
|
|
91
|
+
/** Webhook server configuration (if using webhook mode) */
|
|
92
|
+
webhookServer?: WebhookServerConfig;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Pending text fragment for assembly
|
|
97
|
+
*/
|
|
98
|
+
interface TextFragment {
|
|
99
|
+
chatId: string;
|
|
100
|
+
userId: string;
|
|
101
|
+
messages: Array<{
|
|
102
|
+
messageId: string;
|
|
103
|
+
text: string;
|
|
104
|
+
timestamp: Date;
|
|
105
|
+
ctx: Context;
|
|
106
|
+
}>;
|
|
107
|
+
timer: ReturnType<typeof setTimeout>;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Draft message state for streaming
|
|
112
|
+
*/
|
|
113
|
+
interface DraftState {
|
|
114
|
+
chatId: string;
|
|
115
|
+
messageId?: string;
|
|
116
|
+
currentText: string;
|
|
117
|
+
lastUpdateTime: number;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export class TelegramAdapter implements ChannelAdapter {
|
|
121
|
+
readonly type = 'telegram' as const;
|
|
122
|
+
|
|
123
|
+
private bot: Bot | null = null;
|
|
124
|
+
private _status: ChannelStatus = 'disconnected';
|
|
125
|
+
private _botUsername?: string;
|
|
126
|
+
private messageHandlers: MessageHandler[] = [];
|
|
127
|
+
private errorHandlers: ErrorHandler[] = [];
|
|
128
|
+
private statusHandlers: StatusHandler[] = [];
|
|
129
|
+
private callbackQueryHandlers: CallbackQueryHandler[] = [];
|
|
130
|
+
private config: TelegramAdapterConfig;
|
|
131
|
+
|
|
132
|
+
// Message deduplication: track processed update IDs
|
|
133
|
+
private processedUpdates: Map<number, number> = new Map(); // updateId -> timestamp
|
|
134
|
+
private readonly DEDUP_CACHE_TTL = 60000; // 1 minute
|
|
135
|
+
private readonly DEDUP_CACHE_MAX_SIZE = 1000;
|
|
136
|
+
private dedupCleanupTimer?: ReturnType<typeof setTimeout>;
|
|
137
|
+
|
|
138
|
+
// Text fragment assembly: buffer split messages
|
|
139
|
+
private pendingFragments: Map<string, TextFragment> = new Map(); // chatId:userId -> fragment
|
|
140
|
+
private readonly DEFAULT_FRAGMENT_TIMEOUT = 1500; // 1.5 seconds
|
|
141
|
+
|
|
142
|
+
// Draft streaming state
|
|
143
|
+
private draftStates: Map<string, DraftState> = new Map(); // chatId -> draft state
|
|
144
|
+
private readonly DRAFT_UPDATE_INTERVAL = 500; // Update draft every 500ms
|
|
145
|
+
|
|
146
|
+
// Exponential backoff state
|
|
147
|
+
private backoffAttempt = 0;
|
|
148
|
+
private backoffTimer?: ReturnType<typeof setTimeout>;
|
|
149
|
+
private isReconnecting = false;
|
|
150
|
+
|
|
151
|
+
// Webhook server
|
|
152
|
+
private webhookServer?: http.Server;
|
|
153
|
+
|
|
154
|
+
// Default backoff configuration
|
|
155
|
+
private readonly DEFAULT_BACKOFF: Required<BackoffConfig> = {
|
|
156
|
+
initialDelay: 2000,
|
|
157
|
+
maxDelay: 30000,
|
|
158
|
+
multiplier: 1.8,
|
|
159
|
+
jitter: 0.25,
|
|
160
|
+
maxAttempts: 10,
|
|
161
|
+
};
|
|
162
|
+
|
|
163
|
+
constructor(config: TelegramAdapterConfig) {
|
|
164
|
+
this.config = {
|
|
165
|
+
deduplicationEnabled: true,
|
|
166
|
+
ackReactionEnabled: true,
|
|
167
|
+
draftStreamingEnabled: true,
|
|
168
|
+
fragmentAssemblyTimeout: 1500,
|
|
169
|
+
sequentialProcessingEnabled: true,
|
|
170
|
+
...config,
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
get status(): ChannelStatus {
|
|
175
|
+
return this._status;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
get botUsername(): string | undefined {
|
|
179
|
+
return this._botUsername;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Connect to Telegram using long polling
|
|
184
|
+
*/
|
|
185
|
+
async connect(): Promise<void> {
|
|
186
|
+
if (this._status === 'connected' || this._status === 'connecting') {
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
this.setStatus('connecting');
|
|
191
|
+
this.resetBackoff();
|
|
192
|
+
|
|
193
|
+
try {
|
|
194
|
+
// Create bot instance
|
|
195
|
+
this.bot = new Bot(this.config.botToken);
|
|
196
|
+
|
|
197
|
+
// Add API throttling to prevent rate limits
|
|
198
|
+
const throttler = apiThrottler();
|
|
199
|
+
this.bot.api.config.use(throttler);
|
|
200
|
+
|
|
201
|
+
// Add sequential processing to prevent race conditions
|
|
202
|
+
if (this.config.sequentialProcessingEnabled) {
|
|
203
|
+
this.bot.use(sequentialize(this.getSequentialKey));
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// Get bot info
|
|
207
|
+
const me = await this.bot.api.getMe();
|
|
208
|
+
this._botUsername = me.username;
|
|
209
|
+
|
|
210
|
+
// Register expanded bot commands for the "/" menu
|
|
211
|
+
await this.registerBotCommands();
|
|
212
|
+
|
|
213
|
+
// Set up message handler with deduplication and fragment assembly
|
|
214
|
+
this.bot.on('message:text', async (ctx) => {
|
|
215
|
+
await this.handleTextMessage(ctx);
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
// Set up callback query handler for inline keyboards
|
|
219
|
+
this.bot.on('callback_query:data', async (ctx) => {
|
|
220
|
+
await this.handleCallbackQuery(ctx);
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
// Handle errors with 409 detection and backoff
|
|
224
|
+
this.bot.catch(async (err) => {
|
|
225
|
+
await this.handleBotError(err);
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
// Start deduplication cleanup timer
|
|
229
|
+
if (this.config.deduplicationEnabled) {
|
|
230
|
+
this.startDedupCleanup();
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// Start polling with error handling
|
|
234
|
+
await this.startPolling();
|
|
235
|
+
} catch (error) {
|
|
236
|
+
const err = error instanceof Error ? error : new Error(String(error));
|
|
237
|
+
|
|
238
|
+
// Check for connection conflict (409) during initial connection
|
|
239
|
+
if (this.isConnectionConflictError(error)) {
|
|
240
|
+
console.error('Connection conflict detected: Another bot instance is running');
|
|
241
|
+
this.setStatus('error', new Error('Connection conflict: Another bot instance is running. Stop the other instance first.'));
|
|
242
|
+
throw new Error('Connection conflict: Another bot instance is running');
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
this.setStatus('error', err);
|
|
246
|
+
throw err;
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* Get sequential key for message ordering
|
|
252
|
+
* Messages from the same chat are processed sequentially
|
|
253
|
+
*/
|
|
254
|
+
private getSequentialKey = (ctx: Context): string | undefined => {
|
|
255
|
+
const chatId = ctx.chat?.id;
|
|
256
|
+
if (!chatId) return undefined;
|
|
257
|
+
|
|
258
|
+
// Use chat ID + thread ID for forum topics
|
|
259
|
+
const threadId = ctx.message?.message_thread_id;
|
|
260
|
+
if (threadId) {
|
|
261
|
+
return `${chatId}:${threadId}`;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
return String(chatId);
|
|
265
|
+
};
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* Register bot commands for the "/" menu
|
|
269
|
+
*/
|
|
270
|
+
private async registerBotCommands(): Promise<void> {
|
|
271
|
+
if (!this.bot) return;
|
|
272
|
+
|
|
273
|
+
await this.bot.api.setMyCommands([
|
|
274
|
+
// Core commands
|
|
275
|
+
{ command: 'start', description: 'Start the bot and see welcome message' },
|
|
276
|
+
{ command: 'help', description: 'Show all available commands' },
|
|
277
|
+
{ command: 'status', description: 'Check bot connection and system status' },
|
|
278
|
+
|
|
279
|
+
// Workspace management
|
|
280
|
+
{ command: 'workspaces', description: 'List all available workspaces' },
|
|
281
|
+
{ command: 'workspace', description: 'Select or show current workspace' },
|
|
282
|
+
{ command: 'addworkspace', description: 'Add a new workspace by path' },
|
|
283
|
+
{ command: 'removeworkspace', description: 'Remove a workspace from the list' },
|
|
284
|
+
|
|
285
|
+
// Task management
|
|
286
|
+
{ command: 'newtask', description: 'Start a fresh task/conversation' },
|
|
287
|
+
{ command: 'cancel', description: 'Cancel the current running task' },
|
|
288
|
+
{ command: 'retry', description: 'Retry the last failed task' },
|
|
289
|
+
{ command: 'history', description: 'Show recent task history' },
|
|
290
|
+
|
|
291
|
+
// Model configuration
|
|
292
|
+
{ command: 'provider', description: 'Change or show current LLM provider' },
|
|
293
|
+
{ command: 'providers', description: 'List all available LLM providers' },
|
|
294
|
+
{ command: 'model', description: 'Change or show current model' },
|
|
295
|
+
{ command: 'models', description: 'List available AI models' },
|
|
296
|
+
|
|
297
|
+
// Skills management
|
|
298
|
+
{ command: 'skills', description: 'List available skills' },
|
|
299
|
+
{ command: 'skill', description: 'Enable or disable a skill' },
|
|
300
|
+
|
|
301
|
+
// Settings
|
|
302
|
+
{ command: 'settings', description: 'View and modify bot settings' },
|
|
303
|
+
{ command: 'debug', description: 'Toggle debug mode on/off' },
|
|
304
|
+
{ command: 'version', description: 'Show bot version information' },
|
|
305
|
+
]);
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/**
|
|
309
|
+
* Start polling with error handling and reconnection
|
|
310
|
+
*/
|
|
311
|
+
private async startPolling(): Promise<void> {
|
|
312
|
+
if (!this.bot) return;
|
|
313
|
+
|
|
314
|
+
this.bot.start({
|
|
315
|
+
onStart: () => {
|
|
316
|
+
console.log(`Telegram bot @${this._botUsername} started`);
|
|
317
|
+
this.setStatus('connected');
|
|
318
|
+
this.resetBackoff();
|
|
319
|
+
},
|
|
320
|
+
drop_pending_updates: true,
|
|
321
|
+
allowed_updates: ['message', 'message_reaction', 'callback_query'] as const,
|
|
322
|
+
});
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/**
|
|
326
|
+
* Handle bot errors including 409 conflict detection
|
|
327
|
+
*/
|
|
328
|
+
private async handleBotError(err: unknown): Promise<void> {
|
|
329
|
+
console.error('Telegram bot error:', err);
|
|
330
|
+
|
|
331
|
+
// Check for connection conflict (409)
|
|
332
|
+
if (this.isConnectionConflictError(err)) {
|
|
333
|
+
console.error('Connection conflict detected (409): Another bot instance may be running');
|
|
334
|
+
this.setStatus('error', new Error('Connection conflict: Another bot instance is running'));
|
|
335
|
+
|
|
336
|
+
// Don't reconnect on 409 - let the user resolve the conflict
|
|
337
|
+
this.handleError(new Error('Connection conflict: Another bot instance is running. Stop the other instance and restart.'), 'connection_conflict');
|
|
338
|
+
return;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
// Check for network errors that warrant reconnection
|
|
342
|
+
if (this.isNetworkError(err)) {
|
|
343
|
+
console.log('Network error detected, will attempt reconnection with backoff');
|
|
344
|
+
await this.attemptReconnection();
|
|
345
|
+
return;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
// Handle other errors normally
|
|
349
|
+
this.handleError(err instanceof Error ? err : new Error(String(err)), 'bot.catch');
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
/**
|
|
353
|
+
* Check if error is a connection conflict (409)
|
|
354
|
+
*/
|
|
355
|
+
private isConnectionConflictError(err: unknown): boolean {
|
|
356
|
+
if (err instanceof GrammyError) {
|
|
357
|
+
return err.error_code === 409;
|
|
358
|
+
}
|
|
359
|
+
if (err instanceof HttpError) {
|
|
360
|
+
return (err as HttpError & { status?: number }).status === 409;
|
|
361
|
+
}
|
|
362
|
+
// Check error message for 409 indicators
|
|
363
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
364
|
+
return message.includes('409') || message.includes('Conflict') || message.includes('terminated by other getUpdates');
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
/**
|
|
368
|
+
* Check if error is a network error that warrants reconnection
|
|
369
|
+
*/
|
|
370
|
+
private isNetworkError(err: unknown): boolean {
|
|
371
|
+
if (err instanceof HttpError) {
|
|
372
|
+
return true;
|
|
373
|
+
}
|
|
374
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
375
|
+
return (
|
|
376
|
+
message.includes('ECONNRESET') ||
|
|
377
|
+
message.includes('ETIMEDOUT') ||
|
|
378
|
+
message.includes('ENOTFOUND') ||
|
|
379
|
+
message.includes('network') ||
|
|
380
|
+
message.includes('socket') ||
|
|
381
|
+
message.includes('502') ||
|
|
382
|
+
message.includes('503') ||
|
|
383
|
+
message.includes('504')
|
|
384
|
+
);
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
/**
|
|
388
|
+
* Attempt reconnection with exponential backoff
|
|
389
|
+
*/
|
|
390
|
+
private async attemptReconnection(): Promise<void> {
|
|
391
|
+
if (this.isReconnecting) {
|
|
392
|
+
console.log('Reconnection already in progress');
|
|
393
|
+
return;
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
const backoffConfig = { ...this.DEFAULT_BACKOFF, ...this.config.backoff };
|
|
397
|
+
|
|
398
|
+
if (this.backoffAttempt >= backoffConfig.maxAttempts) {
|
|
399
|
+
console.error(`Max reconnection attempts (${backoffConfig.maxAttempts}) reached`);
|
|
400
|
+
this.setStatus('error', new Error('Max reconnection attempts reached'));
|
|
401
|
+
this.handleError(new Error('Failed to reconnect after maximum attempts'), 'reconnection_failed');
|
|
402
|
+
return;
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
this.isReconnecting = true;
|
|
406
|
+
this.backoffAttempt++;
|
|
407
|
+
|
|
408
|
+
const delay = this.calculateBackoffDelay(backoffConfig);
|
|
409
|
+
console.log(`Reconnection attempt ${this.backoffAttempt}/${backoffConfig.maxAttempts} in ${delay}ms`);
|
|
410
|
+
|
|
411
|
+
this.backoffTimer = setTimeout(async () => {
|
|
412
|
+
try {
|
|
413
|
+
// Stop existing bot if any
|
|
414
|
+
if (this.bot) {
|
|
415
|
+
await this.bot.stop();
|
|
416
|
+
this.bot = null;
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
this.isReconnecting = false;
|
|
420
|
+
this.setStatus('disconnected');
|
|
421
|
+
|
|
422
|
+
// Attempt to reconnect
|
|
423
|
+
await this.connect();
|
|
424
|
+
} catch (error) {
|
|
425
|
+
this.isReconnecting = false;
|
|
426
|
+
console.error('Reconnection attempt failed:', error);
|
|
427
|
+
|
|
428
|
+
// Schedule next attempt if not a 409 conflict
|
|
429
|
+
if (!this.isConnectionConflictError(error)) {
|
|
430
|
+
await this.attemptReconnection();
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
}, delay);
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
/**
|
|
437
|
+
* Calculate backoff delay with jitter
|
|
438
|
+
*/
|
|
439
|
+
private calculateBackoffDelay(config: Required<BackoffConfig>): number {
|
|
440
|
+
// Calculate base delay: initialDelay * multiplier^attempt
|
|
441
|
+
let delay = config.initialDelay * Math.pow(config.multiplier, this.backoffAttempt - 1);
|
|
442
|
+
|
|
443
|
+
// Cap at max delay
|
|
444
|
+
delay = Math.min(delay, config.maxDelay);
|
|
445
|
+
|
|
446
|
+
// Add jitter: delay ± (delay * jitter * random)
|
|
447
|
+
const jitterAmount = delay * config.jitter;
|
|
448
|
+
const jitter = (Math.random() * 2 - 1) * jitterAmount; // Random between -jitterAmount and +jitterAmount
|
|
449
|
+
delay = Math.round(delay + jitter);
|
|
450
|
+
|
|
451
|
+
// Ensure minimum delay of 1 second
|
|
452
|
+
return Math.max(1000, delay);
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
/**
|
|
456
|
+
* Reset backoff state
|
|
457
|
+
*/
|
|
458
|
+
private resetBackoff(): void {
|
|
459
|
+
this.backoffAttempt = 0;
|
|
460
|
+
this.isReconnecting = false;
|
|
461
|
+
if (this.backoffTimer) {
|
|
462
|
+
clearTimeout(this.backoffTimer);
|
|
463
|
+
this.backoffTimer = undefined;
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
/**
|
|
468
|
+
* Handle incoming text message with deduplication and fragment assembly
|
|
469
|
+
*/
|
|
470
|
+
private async handleTextMessage(ctx: Context): Promise<void> {
|
|
471
|
+
const msg = ctx.message!;
|
|
472
|
+
const updateId = ctx.update.update_id;
|
|
473
|
+
|
|
474
|
+
// Feature 4: Message deduplication - check if already processed
|
|
475
|
+
if (this.config.deduplicationEnabled && this.isUpdateProcessed(updateId)) {
|
|
476
|
+
console.log(`Skipping duplicate update ${updateId}`);
|
|
477
|
+
return;
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
// Mark update as processed
|
|
481
|
+
if (this.config.deduplicationEnabled) {
|
|
482
|
+
this.markUpdateProcessed(updateId);
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
// Feature 3: Text fragment assembly - buffer split messages
|
|
486
|
+
const fragmentKey = `${msg.chat.id}:${msg.from!.id}`;
|
|
487
|
+
const existingFragment = this.pendingFragments.get(fragmentKey);
|
|
488
|
+
|
|
489
|
+
if (existingFragment) {
|
|
490
|
+
// Add to existing fragment
|
|
491
|
+
clearTimeout(existingFragment.timer);
|
|
492
|
+
existingFragment.messages.push({
|
|
493
|
+
messageId: msg.message_id.toString(),
|
|
494
|
+
text: msg.text || '',
|
|
495
|
+
timestamp: new Date(msg.date * 1000),
|
|
496
|
+
ctx,
|
|
497
|
+
});
|
|
498
|
+
|
|
499
|
+
// Reset timer
|
|
500
|
+
existingFragment.timer = setTimeout(() => {
|
|
501
|
+
this.processFragments(fragmentKey);
|
|
502
|
+
}, this.config.fragmentAssemblyTimeout || this.DEFAULT_FRAGMENT_TIMEOUT);
|
|
503
|
+
} else {
|
|
504
|
+
// Check if this might be a split message (long text arriving in chunks)
|
|
505
|
+
// Telegram splits messages at ~4096 chars, so check if message ends mid-sentence
|
|
506
|
+
const mightBeSplit = this.mightBeSplitMessage(msg.text || '');
|
|
507
|
+
|
|
508
|
+
if (mightBeSplit) {
|
|
509
|
+
// Start new fragment buffer
|
|
510
|
+
const timer = setTimeout(() => {
|
|
511
|
+
this.processFragments(fragmentKey);
|
|
512
|
+
}, this.config.fragmentAssemblyTimeout || this.DEFAULT_FRAGMENT_TIMEOUT);
|
|
513
|
+
|
|
514
|
+
this.pendingFragments.set(fragmentKey, {
|
|
515
|
+
chatId: msg.chat.id.toString(),
|
|
516
|
+
userId: msg.from!.id.toString(),
|
|
517
|
+
messages: [{
|
|
518
|
+
messageId: msg.message_id.toString(),
|
|
519
|
+
text: msg.text || '',
|
|
520
|
+
timestamp: new Date(msg.date * 1000),
|
|
521
|
+
ctx,
|
|
522
|
+
}],
|
|
523
|
+
timer,
|
|
524
|
+
});
|
|
525
|
+
} else {
|
|
526
|
+
// Process immediately (single message)
|
|
527
|
+
await this.processMessage(ctx);
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
/**
|
|
533
|
+
* Check if a message might be part of a split message
|
|
534
|
+
*/
|
|
535
|
+
private mightBeSplitMessage(text: string): boolean {
|
|
536
|
+
// Messages near Telegram's limit or ending abruptly might be split
|
|
537
|
+
if (text.length >= 4000) return true;
|
|
538
|
+
|
|
539
|
+
// Check if text ends mid-sentence (no terminal punctuation)
|
|
540
|
+
const trimmed = text.trim();
|
|
541
|
+
if (trimmed.length > 100) {
|
|
542
|
+
const lastChar = trimmed.charAt(trimmed.length - 1);
|
|
543
|
+
const terminalPunctuation = ['.', '!', '?', ')', ']', '}', '"', "'", '`'];
|
|
544
|
+
if (!terminalPunctuation.includes(lastChar)) {
|
|
545
|
+
return true;
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
return false;
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
/**
|
|
553
|
+
* Process assembled fragments
|
|
554
|
+
*/
|
|
555
|
+
private async processFragments(fragmentKey: string): Promise<void> {
|
|
556
|
+
const fragment = this.pendingFragments.get(fragmentKey);
|
|
557
|
+
if (!fragment) return;
|
|
558
|
+
|
|
559
|
+
this.pendingFragments.delete(fragmentKey);
|
|
560
|
+
|
|
561
|
+
if (fragment.messages.length === 1) {
|
|
562
|
+
// Single message, process normally
|
|
563
|
+
await this.processMessage(fragment.messages[0].ctx);
|
|
564
|
+
} else {
|
|
565
|
+
// Multiple messages, combine them
|
|
566
|
+
const combinedText = fragment.messages
|
|
567
|
+
.sort((a, b) => a.timestamp.getTime() - b.timestamp.getTime())
|
|
568
|
+
.map(m => m.text)
|
|
569
|
+
.join('');
|
|
570
|
+
|
|
571
|
+
// Use the first message's context but with combined text
|
|
572
|
+
const firstCtx = fragment.messages[0].ctx;
|
|
573
|
+
const message = this.mapContextToMessage(firstCtx, combinedText);
|
|
574
|
+
|
|
575
|
+
console.log(`Assembled ${fragment.messages.length} text fragments into single message (${combinedText.length} chars)`);
|
|
576
|
+
|
|
577
|
+
await this.handleIncomingMessage(message);
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
/**
|
|
582
|
+
* Process a single message (with ACK reaction)
|
|
583
|
+
*/
|
|
584
|
+
private async processMessage(ctx: Context): Promise<void> {
|
|
585
|
+
const message = this.mapContextToMessage(ctx);
|
|
586
|
+
|
|
587
|
+
// Feature 2: Send ACK reaction (👀) while processing
|
|
588
|
+
if (this.config.ackReactionEnabled) {
|
|
589
|
+
try {
|
|
590
|
+
await this.sendAckReaction(ctx);
|
|
591
|
+
} catch (err) {
|
|
592
|
+
// Ignore reaction errors (might not have permission)
|
|
593
|
+
console.debug('Could not send ACK reaction:', err);
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
await this.handleIncomingMessage(message);
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
/**
|
|
601
|
+
* Send ACK reaction (👀) to indicate message received
|
|
602
|
+
*/
|
|
603
|
+
private async sendAckReaction(ctx: Context): Promise<void> {
|
|
604
|
+
if (!this.bot || !ctx.message) return;
|
|
605
|
+
|
|
606
|
+
try {
|
|
607
|
+
await this.bot.api.setMessageReaction(
|
|
608
|
+
ctx.message.chat.id,
|
|
609
|
+
ctx.message.message_id,
|
|
610
|
+
[{ type: 'emoji', emoji: '👀' }]
|
|
611
|
+
);
|
|
612
|
+
} catch {
|
|
613
|
+
// Silently fail - reactions might not be available
|
|
614
|
+
}
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
/**
|
|
618
|
+
* Remove ACK reaction after processing
|
|
619
|
+
*/
|
|
620
|
+
async removeAckReaction(chatId: string, messageId: string): Promise<void> {
|
|
621
|
+
if (!this.bot) return;
|
|
622
|
+
|
|
623
|
+
try {
|
|
624
|
+
await this.bot.api.setMessageReaction(
|
|
625
|
+
chatId,
|
|
626
|
+
parseInt(messageId, 10),
|
|
627
|
+
[] // Empty array removes reactions
|
|
628
|
+
);
|
|
629
|
+
} catch {
|
|
630
|
+
// Silently fail
|
|
631
|
+
}
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
/**
|
|
635
|
+
* Send a completion reaction when done
|
|
636
|
+
* Note: Telegram only allows specific reaction emojis, using 👍 for completion
|
|
637
|
+
*/
|
|
638
|
+
async sendCompletionReaction(chatId: string, messageId: string): Promise<void> {
|
|
639
|
+
if (!this.bot) return;
|
|
640
|
+
|
|
641
|
+
try {
|
|
642
|
+
await this.bot.api.setMessageReaction(
|
|
643
|
+
chatId,
|
|
644
|
+
parseInt(messageId, 10),
|
|
645
|
+
[{ type: 'emoji', emoji: '👍' }]
|
|
646
|
+
);
|
|
647
|
+
} catch {
|
|
648
|
+
// Silently fail
|
|
649
|
+
}
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
/**
|
|
653
|
+
* Check if update was already processed (deduplication)
|
|
654
|
+
*/
|
|
655
|
+
private isUpdateProcessed(updateId: number): boolean {
|
|
656
|
+
return this.processedUpdates.has(updateId);
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
/**
|
|
660
|
+
* Mark update as processed
|
|
661
|
+
*/
|
|
662
|
+
private markUpdateProcessed(updateId: number): void {
|
|
663
|
+
this.processedUpdates.set(updateId, Date.now());
|
|
664
|
+
|
|
665
|
+
// Prevent unbounded growth
|
|
666
|
+
if (this.processedUpdates.size > this.DEDUP_CACHE_MAX_SIZE) {
|
|
667
|
+
this.cleanupDedupCache();
|
|
668
|
+
}
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
/**
|
|
672
|
+
* Start periodic cleanup of dedup cache
|
|
673
|
+
*/
|
|
674
|
+
private startDedupCleanup(): void {
|
|
675
|
+
this.dedupCleanupTimer = setInterval(() => {
|
|
676
|
+
this.cleanupDedupCache();
|
|
677
|
+
}, this.DEDUP_CACHE_TTL);
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
/**
|
|
681
|
+
* Clean up old entries from dedup cache
|
|
682
|
+
*/
|
|
683
|
+
private cleanupDedupCache(): void {
|
|
684
|
+
const now = Date.now();
|
|
685
|
+
for (const [updateId, timestamp] of this.processedUpdates) {
|
|
686
|
+
if (now - timestamp > this.DEDUP_CACHE_TTL) {
|
|
687
|
+
this.processedUpdates.delete(updateId);
|
|
688
|
+
}
|
|
689
|
+
}
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
/**
|
|
693
|
+
* Disconnect from Telegram
|
|
694
|
+
*/
|
|
695
|
+
async disconnect(): Promise<void> {
|
|
696
|
+
// Reset backoff state
|
|
697
|
+
this.resetBackoff();
|
|
698
|
+
|
|
699
|
+
// Clear timers
|
|
700
|
+
if (this.dedupCleanupTimer) {
|
|
701
|
+
clearInterval(this.dedupCleanupTimer);
|
|
702
|
+
this.dedupCleanupTimer = undefined;
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
// Clear pending fragments
|
|
706
|
+
for (const fragment of this.pendingFragments.values()) {
|
|
707
|
+
clearTimeout(fragment.timer);
|
|
708
|
+
}
|
|
709
|
+
this.pendingFragments.clear();
|
|
710
|
+
|
|
711
|
+
// Clear draft states
|
|
712
|
+
this.draftStates.clear();
|
|
713
|
+
|
|
714
|
+
// Clear dedup cache
|
|
715
|
+
this.processedUpdates.clear();
|
|
716
|
+
|
|
717
|
+
// Stop webhook server if running
|
|
718
|
+
if (this.webhookServer) {
|
|
719
|
+
await this.stopWebhookServer();
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
if (this.bot) {
|
|
723
|
+
await this.bot.stop();
|
|
724
|
+
this.bot = null;
|
|
725
|
+
}
|
|
726
|
+
this._botUsername = undefined;
|
|
727
|
+
this.setStatus('disconnected');
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
/**
|
|
731
|
+
* Send a message to a Telegram chat
|
|
732
|
+
*/
|
|
733
|
+
async sendMessage(message: OutgoingMessage): Promise<string> {
|
|
734
|
+
if (!this.bot || this._status !== 'connected') {
|
|
735
|
+
throw new Error('Telegram bot is not connected');
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
// Handle image attachments first (send images before text)
|
|
739
|
+
let lastMessageId: string | undefined;
|
|
740
|
+
if (message.attachments && message.attachments.length > 0) {
|
|
741
|
+
for (const attachment of message.attachments) {
|
|
742
|
+
if (attachment.type === 'image' && attachment.url) {
|
|
743
|
+
try {
|
|
744
|
+
// attachment.url is the file path for local images
|
|
745
|
+
const msgId = await this.sendPhoto(message.chatId, attachment.url);
|
|
746
|
+
lastMessageId = msgId;
|
|
747
|
+
} catch (err) {
|
|
748
|
+
console.error('Failed to send image attachment:', err);
|
|
749
|
+
}
|
|
750
|
+
}
|
|
751
|
+
}
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
// If we have text to send, send it
|
|
755
|
+
if (message.text && message.text.trim()) {
|
|
756
|
+
// Process text for Telegram compatibility
|
|
757
|
+
let processedText = message.text;
|
|
758
|
+
if (message.parseMode === 'markdown') {
|
|
759
|
+
processedText = this.convertMarkdownForTelegram(message.text);
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
const options: Record<string, unknown> = {};
|
|
763
|
+
|
|
764
|
+
// Set parse mode
|
|
765
|
+
// Use legacy Markdown (not MarkdownV2) to avoid escaping issues with special characters
|
|
766
|
+
if (message.parseMode === 'markdown') {
|
|
767
|
+
options.parse_mode = 'Markdown';
|
|
768
|
+
} else if (message.parseMode === 'html') {
|
|
769
|
+
options.parse_mode = 'HTML';
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
// Reply to message if specified
|
|
773
|
+
if (message.replyTo) {
|
|
774
|
+
options.reply_to_message_id = parseInt(message.replyTo, 10);
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
// Forum topic thread support
|
|
778
|
+
if (message.threadId) {
|
|
779
|
+
options.message_thread_id = parseInt(message.threadId, 10);
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
// Link preview control
|
|
783
|
+
if (message.disableLinkPreview) {
|
|
784
|
+
options.link_preview_options = { is_disabled: true };
|
|
785
|
+
}
|
|
786
|
+
|
|
787
|
+
// Inline keyboard support
|
|
788
|
+
if (message.inlineKeyboard && message.inlineKeyboard.length > 0) {
|
|
789
|
+
options.reply_markup = this.buildInlineKeyboard(message.inlineKeyboard);
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
try {
|
|
793
|
+
const sent = await this.bot.api.sendMessage(message.chatId, processedText, options);
|
|
794
|
+
return sent.message_id.toString();
|
|
795
|
+
} catch (error: any) {
|
|
796
|
+
// If markdown parsing fails, retry without parse_mode
|
|
797
|
+
if (error?.error_code === 400 && error?.description?.includes("can't parse entities")) {
|
|
798
|
+
console.log('Markdown parsing failed, retrying without parse_mode');
|
|
799
|
+
const plainOptions: Record<string, unknown> = {
|
|
800
|
+
...(message.threadId && { message_thread_id: parseInt(message.threadId, 10) }),
|
|
801
|
+
...(message.disableLinkPreview && { link_preview_options: { is_disabled: true } }),
|
|
802
|
+
...(message.inlineKeyboard && { reply_markup: this.buildInlineKeyboard(message.inlineKeyboard) }),
|
|
803
|
+
};
|
|
804
|
+
if (message.replyTo) {
|
|
805
|
+
plainOptions.reply_to_message_id = parseInt(message.replyTo, 10);
|
|
806
|
+
}
|
|
807
|
+
const sent = await this.bot.api.sendMessage(message.chatId, message.text, plainOptions);
|
|
808
|
+
return sent.message_id.toString();
|
|
809
|
+
}
|
|
810
|
+
throw error;
|
|
811
|
+
}
|
|
812
|
+
}
|
|
813
|
+
|
|
814
|
+
// If no text but had attachments, return the last attachment message ID
|
|
815
|
+
return lastMessageId || '';
|
|
816
|
+
}
|
|
817
|
+
|
|
818
|
+
/**
|
|
819
|
+
* Build grammY InlineKeyboard from our button format
|
|
820
|
+
*/
|
|
821
|
+
private buildInlineKeyboard(buttons: InlineKeyboardButton[][]): InlineKeyboard {
|
|
822
|
+
const keyboard = new InlineKeyboard();
|
|
823
|
+
for (const row of buttons) {
|
|
824
|
+
for (const button of row) {
|
|
825
|
+
if (button.url) {
|
|
826
|
+
keyboard.url(button.text, button.url);
|
|
827
|
+
} else if (button.callbackData) {
|
|
828
|
+
keyboard.text(button.text, button.callbackData);
|
|
829
|
+
}
|
|
830
|
+
}
|
|
831
|
+
keyboard.row();
|
|
832
|
+
}
|
|
833
|
+
return keyboard;
|
|
834
|
+
}
|
|
835
|
+
|
|
836
|
+
/**
|
|
837
|
+
* Handle incoming callback query from inline keyboard button press
|
|
838
|
+
*/
|
|
839
|
+
private async handleCallbackQuery(ctx: Context): Promise<void> {
|
|
840
|
+
const query = ctx.callbackQuery!;
|
|
841
|
+
if (!query.data || !query.message) {
|
|
842
|
+
return;
|
|
843
|
+
}
|
|
844
|
+
|
|
845
|
+
const callbackQuery: CallbackQuery = {
|
|
846
|
+
id: query.id,
|
|
847
|
+
userId: query.from.id.toString(),
|
|
848
|
+
userName: query.from.first_name + (query.from.last_name ? ` ${query.from.last_name}` : ''),
|
|
849
|
+
chatId: query.message.chat.id.toString(),
|
|
850
|
+
messageId: query.message.message_id.toString(),
|
|
851
|
+
data: query.data,
|
|
852
|
+
threadId: (query.message as { message_thread_id?: number }).message_thread_id?.toString(),
|
|
853
|
+
raw: ctx,
|
|
854
|
+
};
|
|
855
|
+
|
|
856
|
+
// Notify all registered handlers
|
|
857
|
+
for (const handler of this.callbackQueryHandlers) {
|
|
858
|
+
try {
|
|
859
|
+
await handler(callbackQuery);
|
|
860
|
+
} catch (error) {
|
|
861
|
+
console.error('Error in callback query handler:', error);
|
|
862
|
+
this.handleError(
|
|
863
|
+
error instanceof Error ? error : new Error(String(error)),
|
|
864
|
+
'callbackQueryHandler'
|
|
865
|
+
);
|
|
866
|
+
}
|
|
867
|
+
}
|
|
868
|
+
}
|
|
869
|
+
|
|
870
|
+
/**
|
|
871
|
+
* Feature 1: Draft streaming - Start streaming a response
|
|
872
|
+
* Creates or updates a draft message that shows response as it generates
|
|
873
|
+
*/
|
|
874
|
+
async startDraftStream(chatId: string): Promise<void> {
|
|
875
|
+
if (!this.config.draftStreamingEnabled) return;
|
|
876
|
+
|
|
877
|
+
this.draftStates.set(chatId, {
|
|
878
|
+
chatId,
|
|
879
|
+
currentText: '',
|
|
880
|
+
lastUpdateTime: Date.now(),
|
|
881
|
+
});
|
|
882
|
+
}
|
|
883
|
+
|
|
884
|
+
/**
|
|
885
|
+
* Update draft stream with new content
|
|
886
|
+
*/
|
|
887
|
+
async updateDraftStream(chatId: string, text: string): Promise<void> {
|
|
888
|
+
if (!this.bot || !this.config.draftStreamingEnabled) return;
|
|
889
|
+
|
|
890
|
+
const state = this.draftStates.get(chatId);
|
|
891
|
+
if (!state) return;
|
|
892
|
+
|
|
893
|
+
const now = Date.now();
|
|
894
|
+
|
|
895
|
+
// Throttle updates to prevent API spam
|
|
896
|
+
if (now - state.lastUpdateTime < this.DRAFT_UPDATE_INTERVAL) {
|
|
897
|
+
// Just update the text, don't send yet
|
|
898
|
+
state.currentText = text;
|
|
899
|
+
return;
|
|
900
|
+
}
|
|
901
|
+
|
|
902
|
+
// Add typing indicator suffix
|
|
903
|
+
const displayText = text + ' ▌';
|
|
904
|
+
|
|
905
|
+
try {
|
|
906
|
+
if (state.messageId) {
|
|
907
|
+
// Edit existing message
|
|
908
|
+
await this.bot.api.editMessageText(
|
|
909
|
+
chatId,
|
|
910
|
+
parseInt(state.messageId, 10),
|
|
911
|
+
displayText
|
|
912
|
+
);
|
|
913
|
+
} else {
|
|
914
|
+
// Create new message
|
|
915
|
+
const sent = await this.bot.api.sendMessage(chatId, displayText);
|
|
916
|
+
state.messageId = sent.message_id.toString();
|
|
917
|
+
}
|
|
918
|
+
|
|
919
|
+
state.currentText = text;
|
|
920
|
+
state.lastUpdateTime = now;
|
|
921
|
+
} catch (error: any) {
|
|
922
|
+
// Ignore "message not modified" errors
|
|
923
|
+
if (!error?.description?.includes('message is not modified')) {
|
|
924
|
+
console.error('Draft stream update error:', error);
|
|
925
|
+
}
|
|
926
|
+
}
|
|
927
|
+
}
|
|
928
|
+
|
|
929
|
+
/**
|
|
930
|
+
* Finalize draft stream with final content
|
|
931
|
+
*/
|
|
932
|
+
async finalizeDraftStream(chatId: string, finalText: string): Promise<string> {
|
|
933
|
+
if (!this.bot) throw new Error('Bot not connected');
|
|
934
|
+
|
|
935
|
+
const state = this.draftStates.get(chatId);
|
|
936
|
+
this.draftStates.delete(chatId);
|
|
937
|
+
|
|
938
|
+
if (!this.config.draftStreamingEnabled || !state?.messageId) {
|
|
939
|
+
// No draft exists, send as new message
|
|
940
|
+
const sent = await this.bot.api.sendMessage(chatId, finalText);
|
|
941
|
+
return sent.message_id.toString();
|
|
942
|
+
}
|
|
943
|
+
|
|
944
|
+
try {
|
|
945
|
+
// Edit the draft message to final content (remove typing indicator)
|
|
946
|
+
await this.bot.api.editMessageText(
|
|
947
|
+
chatId,
|
|
948
|
+
parseInt(state.messageId, 10),
|
|
949
|
+
finalText
|
|
950
|
+
);
|
|
951
|
+
return state.messageId;
|
|
952
|
+
} catch (error: any) {
|
|
953
|
+
// If edit fails, send as new message
|
|
954
|
+
console.error('Failed to finalize draft, sending new message:', error);
|
|
955
|
+
const sent = await this.bot.api.sendMessage(chatId, finalText);
|
|
956
|
+
return sent.message_id.toString();
|
|
957
|
+
}
|
|
958
|
+
}
|
|
959
|
+
|
|
960
|
+
/**
|
|
961
|
+
* Cancel draft stream (delete the draft message)
|
|
962
|
+
*/
|
|
963
|
+
async cancelDraftStream(chatId: string): Promise<void> {
|
|
964
|
+
const state = this.draftStates.get(chatId);
|
|
965
|
+
this.draftStates.delete(chatId);
|
|
966
|
+
|
|
967
|
+
if (state?.messageId && this.bot) {
|
|
968
|
+
try {
|
|
969
|
+
await this.bot.api.deleteMessage(chatId, parseInt(state.messageId, 10));
|
|
970
|
+
} catch {
|
|
971
|
+
// Ignore deletion errors
|
|
972
|
+
}
|
|
973
|
+
}
|
|
974
|
+
}
|
|
975
|
+
|
|
976
|
+
/**
|
|
977
|
+
* Convert GitHub-flavored markdown to Telegram-compatible format
|
|
978
|
+
* Telegram legacy Markdown only supports: *bold*, _italic_, `code`, ```code blocks```, [links](url)
|
|
979
|
+
*/
|
|
980
|
+
private convertMarkdownForTelegram(text: string): string {
|
|
981
|
+
let result = text;
|
|
982
|
+
|
|
983
|
+
// Convert markdown headers (## Header) to bold (*Header*)
|
|
984
|
+
// Must be done before ** conversion
|
|
985
|
+
result = result.replace(/^#{1,6}\s+(.+)$/gm, '*$1*');
|
|
986
|
+
|
|
987
|
+
// Convert markdown tables to code blocks
|
|
988
|
+
// Tables start with | and have a separator line like |---|---|
|
|
989
|
+
const tableRegex = /(\|[^\n]+\|\n)+/g;
|
|
990
|
+
const hasSeparatorLine = /\|[\s-:]+\|/;
|
|
991
|
+
|
|
992
|
+
result = result.replace(tableRegex, (match) => {
|
|
993
|
+
// Check if this looks like a table (has separator line with dashes)
|
|
994
|
+
if (hasSeparatorLine.test(match)) {
|
|
995
|
+
// Convert table to code block for monospace display
|
|
996
|
+
// Remove the separator line (|---|---|) as it's just formatting
|
|
997
|
+
const lines = match.split('\n').filter(line => line.trim());
|
|
998
|
+
const cleanedLines = lines.filter(line => !(/^\|[\s-:]+\|$/.test(line.trim())));
|
|
999
|
+
|
|
1000
|
+
// Format table nicely
|
|
1001
|
+
const formattedTable = cleanedLines.map(line => {
|
|
1002
|
+
// Remove leading/trailing pipes and clean up
|
|
1003
|
+
return line.replace(/^\||\|$/g, '').trim();
|
|
1004
|
+
}).join('\n');
|
|
1005
|
+
|
|
1006
|
+
return '```\n' + formattedTable + '\n```\n';
|
|
1007
|
+
}
|
|
1008
|
+
return match;
|
|
1009
|
+
});
|
|
1010
|
+
|
|
1011
|
+
// Convert **bold** to *bold* (Telegram uses single asterisk)
|
|
1012
|
+
result = result.replace(/\*\*([^*]+)\*\*/g, '*$1*');
|
|
1013
|
+
|
|
1014
|
+
// Convert __bold__ to *bold* (alternative bold syntax)
|
|
1015
|
+
result = result.replace(/__([^_]+)__/g, '*$1*');
|
|
1016
|
+
|
|
1017
|
+
// Convert horizontal rules (---, ***) to a line
|
|
1018
|
+
result = result.replace(/^[-*]{3,}$/gm, '─────────────────');
|
|
1019
|
+
|
|
1020
|
+
return result;
|
|
1021
|
+
}
|
|
1022
|
+
|
|
1023
|
+
/**
|
|
1024
|
+
* Edit an existing message
|
|
1025
|
+
*/
|
|
1026
|
+
async editMessage(chatId: string, messageId: string, text: string): Promise<void> {
|
|
1027
|
+
if (!this.bot || this._status !== 'connected') {
|
|
1028
|
+
throw new Error('Telegram bot is not connected');
|
|
1029
|
+
}
|
|
1030
|
+
|
|
1031
|
+
const msgId = parseInt(messageId, 10);
|
|
1032
|
+
if (isNaN(msgId)) {
|
|
1033
|
+
throw new Error(`Invalid message ID: ${messageId}`);
|
|
1034
|
+
}
|
|
1035
|
+
|
|
1036
|
+
await this.bot.api.editMessageText(chatId, msgId, text);
|
|
1037
|
+
}
|
|
1038
|
+
|
|
1039
|
+
/**
|
|
1040
|
+
* Delete a message
|
|
1041
|
+
*/
|
|
1042
|
+
async deleteMessage(chatId: string, messageId: string): Promise<void> {
|
|
1043
|
+
if (!this.bot || this._status !== 'connected') {
|
|
1044
|
+
throw new Error('Telegram bot is not connected');
|
|
1045
|
+
}
|
|
1046
|
+
|
|
1047
|
+
const msgId = parseInt(messageId, 10);
|
|
1048
|
+
if (isNaN(msgId)) {
|
|
1049
|
+
throw new Error(`Invalid message ID: ${messageId}`);
|
|
1050
|
+
}
|
|
1051
|
+
|
|
1052
|
+
await this.bot.api.deleteMessage(chatId, msgId);
|
|
1053
|
+
}
|
|
1054
|
+
|
|
1055
|
+
/**
|
|
1056
|
+
* Send a document/file to a chat
|
|
1057
|
+
*/
|
|
1058
|
+
async sendDocument(chatId: string, filePath: string, caption?: string): Promise<string> {
|
|
1059
|
+
if (!this.bot || this._status !== 'connected') {
|
|
1060
|
+
throw new Error('Telegram bot is not connected');
|
|
1061
|
+
}
|
|
1062
|
+
|
|
1063
|
+
// Check if file exists
|
|
1064
|
+
if (!fs.existsSync(filePath)) {
|
|
1065
|
+
throw new Error(`File not found: ${filePath}`);
|
|
1066
|
+
}
|
|
1067
|
+
|
|
1068
|
+
const fileName = path.basename(filePath);
|
|
1069
|
+
const fileBuffer = fs.readFileSync(filePath);
|
|
1070
|
+
|
|
1071
|
+
const sent = await this.bot.api.sendDocument(
|
|
1072
|
+
chatId,
|
|
1073
|
+
new InputFile(fileBuffer, fileName),
|
|
1074
|
+
{ caption }
|
|
1075
|
+
);
|
|
1076
|
+
|
|
1077
|
+
return sent.message_id.toString();
|
|
1078
|
+
}
|
|
1079
|
+
|
|
1080
|
+
/**
|
|
1081
|
+
* Send a photo/image to a chat
|
|
1082
|
+
*/
|
|
1083
|
+
async sendPhoto(chatId: string, filePath: string, caption?: string): Promise<string> {
|
|
1084
|
+
if (!this.bot || this._status !== 'connected') {
|
|
1085
|
+
throw new Error('Telegram bot is not connected');
|
|
1086
|
+
}
|
|
1087
|
+
|
|
1088
|
+
// Check if file exists
|
|
1089
|
+
if (!fs.existsSync(filePath)) {
|
|
1090
|
+
throw new Error(`File not found: ${filePath}`);
|
|
1091
|
+
}
|
|
1092
|
+
|
|
1093
|
+
const fileName = path.basename(filePath);
|
|
1094
|
+
const fileBuffer = fs.readFileSync(filePath);
|
|
1095
|
+
|
|
1096
|
+
const sent = await this.bot.api.sendPhoto(
|
|
1097
|
+
chatId,
|
|
1098
|
+
new InputFile(fileBuffer, fileName),
|
|
1099
|
+
{ caption }
|
|
1100
|
+
);
|
|
1101
|
+
|
|
1102
|
+
return sent.message_id.toString();
|
|
1103
|
+
}
|
|
1104
|
+
|
|
1105
|
+
/**
|
|
1106
|
+
* Register a message handler
|
|
1107
|
+
*/
|
|
1108
|
+
onMessage(handler: MessageHandler): void {
|
|
1109
|
+
this.messageHandlers.push(handler);
|
|
1110
|
+
}
|
|
1111
|
+
|
|
1112
|
+
/**
|
|
1113
|
+
* Register a callback query handler (for inline keyboard buttons)
|
|
1114
|
+
*/
|
|
1115
|
+
onCallbackQuery(handler: CallbackQueryHandler): void {
|
|
1116
|
+
this.callbackQueryHandlers.push(handler);
|
|
1117
|
+
}
|
|
1118
|
+
|
|
1119
|
+
/**
|
|
1120
|
+
* Answer a callback query (acknowledge button press)
|
|
1121
|
+
* Call this to remove the loading state from the button.
|
|
1122
|
+
*/
|
|
1123
|
+
async answerCallbackQuery(queryId: string, text?: string, showAlert?: boolean): Promise<void> {
|
|
1124
|
+
if (!this.bot || this._status !== 'connected') {
|
|
1125
|
+
throw new Error('Telegram bot is not connected');
|
|
1126
|
+
}
|
|
1127
|
+
|
|
1128
|
+
await this.bot.api.answerCallbackQuery(queryId, {
|
|
1129
|
+
text,
|
|
1130
|
+
show_alert: showAlert,
|
|
1131
|
+
});
|
|
1132
|
+
}
|
|
1133
|
+
|
|
1134
|
+
/**
|
|
1135
|
+
* Edit a message with a new inline keyboard
|
|
1136
|
+
*/
|
|
1137
|
+
async editMessageWithKeyboard(
|
|
1138
|
+
chatId: string,
|
|
1139
|
+
messageId: string,
|
|
1140
|
+
text?: string,
|
|
1141
|
+
inlineKeyboard?: InlineKeyboardButton[][]
|
|
1142
|
+
): Promise<void> {
|
|
1143
|
+
if (!this.bot || this._status !== 'connected') {
|
|
1144
|
+
throw new Error('Telegram bot is not connected');
|
|
1145
|
+
}
|
|
1146
|
+
|
|
1147
|
+
const msgId = parseInt(messageId, 10);
|
|
1148
|
+
if (isNaN(msgId)) {
|
|
1149
|
+
throw new Error(`Invalid message ID: ${messageId}`);
|
|
1150
|
+
}
|
|
1151
|
+
|
|
1152
|
+
const options: Record<string, unknown> = {};
|
|
1153
|
+
if (inlineKeyboard && inlineKeyboard.length > 0) {
|
|
1154
|
+
options.reply_markup = this.buildInlineKeyboard(inlineKeyboard);
|
|
1155
|
+
}
|
|
1156
|
+
|
|
1157
|
+
if (text) {
|
|
1158
|
+
await this.bot.api.editMessageText(chatId, msgId, text, options);
|
|
1159
|
+
} else if (inlineKeyboard) {
|
|
1160
|
+
await this.bot.api.editMessageReplyMarkup(chatId, msgId, options);
|
|
1161
|
+
}
|
|
1162
|
+
}
|
|
1163
|
+
|
|
1164
|
+
// ============================================================================
|
|
1165
|
+
// Extended Features
|
|
1166
|
+
// ============================================================================
|
|
1167
|
+
|
|
1168
|
+
/**
|
|
1169
|
+
* Send typing indicator (chat action)
|
|
1170
|
+
*/
|
|
1171
|
+
async sendTyping(chatId: string, threadId?: string): Promise<void> {
|
|
1172
|
+
if (!this.bot || this._status !== 'connected') {
|
|
1173
|
+
throw new Error('Telegram bot is not connected');
|
|
1174
|
+
}
|
|
1175
|
+
|
|
1176
|
+
const options: Record<string, unknown> = {};
|
|
1177
|
+
if (threadId) {
|
|
1178
|
+
options.message_thread_id = parseInt(threadId, 10);
|
|
1179
|
+
}
|
|
1180
|
+
|
|
1181
|
+
await this.bot.api.sendChatAction(chatId, 'typing', options);
|
|
1182
|
+
}
|
|
1183
|
+
|
|
1184
|
+
/**
|
|
1185
|
+
* Add reaction to a message
|
|
1186
|
+
*/
|
|
1187
|
+
async addReaction(chatId: string, messageId: string, emoji: string): Promise<void> {
|
|
1188
|
+
if (!this.bot || this._status !== 'connected') {
|
|
1189
|
+
throw new Error('Telegram bot is not connected');
|
|
1190
|
+
}
|
|
1191
|
+
|
|
1192
|
+
const msgId = parseInt(messageId, 10);
|
|
1193
|
+
// Cast emoji to the expected type - Telegram will reject invalid emojis at runtime
|
|
1194
|
+
await this.bot.api.setMessageReaction(chatId, msgId, [{ type: 'emoji', emoji: emoji as '👍' }]);
|
|
1195
|
+
}
|
|
1196
|
+
|
|
1197
|
+
/**
|
|
1198
|
+
* Remove reaction from a message
|
|
1199
|
+
*/
|
|
1200
|
+
async removeReaction(chatId: string, messageId: string): Promise<void> {
|
|
1201
|
+
if (!this.bot || this._status !== 'connected') {
|
|
1202
|
+
throw new Error('Telegram bot is not connected');
|
|
1203
|
+
}
|
|
1204
|
+
|
|
1205
|
+
const msgId = parseInt(messageId, 10);
|
|
1206
|
+
await this.bot.api.setMessageReaction(chatId, msgId, []);
|
|
1207
|
+
}
|
|
1208
|
+
|
|
1209
|
+
/**
|
|
1210
|
+
* Send a poll
|
|
1211
|
+
*/
|
|
1212
|
+
async sendPoll(chatId: string, poll: Poll, threadId?: string): Promise<string> {
|
|
1213
|
+
if (!this.bot || this._status !== 'connected') {
|
|
1214
|
+
throw new Error('Telegram bot is not connected');
|
|
1215
|
+
}
|
|
1216
|
+
|
|
1217
|
+
const options: Record<string, unknown> = {
|
|
1218
|
+
is_anonymous: poll.isAnonymous ?? true,
|
|
1219
|
+
allows_multiple_answers: poll.allowsMultipleAnswers ?? false,
|
|
1220
|
+
};
|
|
1221
|
+
|
|
1222
|
+
if (threadId) {
|
|
1223
|
+
options.message_thread_id = parseInt(threadId, 10);
|
|
1224
|
+
}
|
|
1225
|
+
|
|
1226
|
+
if (poll.type === 'quiz' && poll.correctOptionId !== undefined) {
|
|
1227
|
+
options.type = 'quiz';
|
|
1228
|
+
options.correct_option_id = poll.correctOptionId;
|
|
1229
|
+
if (poll.explanation) {
|
|
1230
|
+
options.explanation = poll.explanation;
|
|
1231
|
+
}
|
|
1232
|
+
}
|
|
1233
|
+
|
|
1234
|
+
if (poll.openPeriod) {
|
|
1235
|
+
options.open_period = poll.openPeriod;
|
|
1236
|
+
} else if (poll.closeDate) {
|
|
1237
|
+
options.close_date = Math.floor(poll.closeDate.getTime() / 1000);
|
|
1238
|
+
}
|
|
1239
|
+
|
|
1240
|
+
const sent = await this.bot.api.sendPoll(
|
|
1241
|
+
chatId,
|
|
1242
|
+
poll.question,
|
|
1243
|
+
poll.options.map(o => o.text),
|
|
1244
|
+
options
|
|
1245
|
+
);
|
|
1246
|
+
|
|
1247
|
+
return sent.message_id.toString();
|
|
1248
|
+
}
|
|
1249
|
+
|
|
1250
|
+
/**
|
|
1251
|
+
* Send message with reply keyboard (persistent keyboard below input)
|
|
1252
|
+
*/
|
|
1253
|
+
async sendWithReplyKeyboard(
|
|
1254
|
+
chatId: string,
|
|
1255
|
+
text: string,
|
|
1256
|
+
keyboard: ReplyKeyboard,
|
|
1257
|
+
threadId?: string
|
|
1258
|
+
): Promise<string> {
|
|
1259
|
+
if (!this.bot || this._status !== 'connected') {
|
|
1260
|
+
throw new Error('Telegram bot is not connected');
|
|
1261
|
+
}
|
|
1262
|
+
|
|
1263
|
+
const replyMarkup = {
|
|
1264
|
+
keyboard: keyboard.buttons.map(row =>
|
|
1265
|
+
row.map(btn => ({
|
|
1266
|
+
text: btn.text,
|
|
1267
|
+
request_contact: btn.requestContact,
|
|
1268
|
+
request_location: btn.requestLocation,
|
|
1269
|
+
}))
|
|
1270
|
+
),
|
|
1271
|
+
resize_keyboard: keyboard.resizeKeyboard ?? true,
|
|
1272
|
+
one_time_keyboard: keyboard.oneTimeKeyboard ?? false,
|
|
1273
|
+
input_field_placeholder: keyboard.inputPlaceholder,
|
|
1274
|
+
};
|
|
1275
|
+
|
|
1276
|
+
const options: Record<string, unknown> = {
|
|
1277
|
+
reply_markup: replyMarkup,
|
|
1278
|
+
};
|
|
1279
|
+
|
|
1280
|
+
if (threadId) {
|
|
1281
|
+
options.message_thread_id = parseInt(threadId, 10);
|
|
1282
|
+
}
|
|
1283
|
+
|
|
1284
|
+
const sent = await this.bot.api.sendMessage(chatId, text, options);
|
|
1285
|
+
return sent.message_id.toString();
|
|
1286
|
+
}
|
|
1287
|
+
|
|
1288
|
+
/**
|
|
1289
|
+
* Remove reply keyboard (send message that hides the keyboard)
|
|
1290
|
+
*/
|
|
1291
|
+
async removeReplyKeyboard(chatId: string, text: string, threadId?: string): Promise<string> {
|
|
1292
|
+
if (!this.bot || this._status !== 'connected') {
|
|
1293
|
+
throw new Error('Telegram bot is not connected');
|
|
1294
|
+
}
|
|
1295
|
+
|
|
1296
|
+
const options: Record<string, unknown> = {
|
|
1297
|
+
reply_markup: { remove_keyboard: true },
|
|
1298
|
+
};
|
|
1299
|
+
|
|
1300
|
+
if (threadId) {
|
|
1301
|
+
options.message_thread_id = parseInt(threadId, 10);
|
|
1302
|
+
}
|
|
1303
|
+
|
|
1304
|
+
const sent = await this.bot.api.sendMessage(chatId, text, options);
|
|
1305
|
+
return sent.message_id.toString();
|
|
1306
|
+
}
|
|
1307
|
+
|
|
1308
|
+
/**
|
|
1309
|
+
* Send a sticker
|
|
1310
|
+
*/
|
|
1311
|
+
async sendSticker(chatId: string, stickerId: string, threadId?: string): Promise<string> {
|
|
1312
|
+
if (!this.bot || this._status !== 'connected') {
|
|
1313
|
+
throw new Error('Telegram bot is not connected');
|
|
1314
|
+
}
|
|
1315
|
+
|
|
1316
|
+
const options: Record<string, unknown> = {};
|
|
1317
|
+
if (threadId) {
|
|
1318
|
+
options.message_thread_id = parseInt(threadId, 10);
|
|
1319
|
+
}
|
|
1320
|
+
|
|
1321
|
+
const sent = await this.bot.api.sendSticker(chatId, stickerId, options);
|
|
1322
|
+
return sent.message_id.toString();
|
|
1323
|
+
}
|
|
1324
|
+
|
|
1325
|
+
/**
|
|
1326
|
+
* Send location
|
|
1327
|
+
*/
|
|
1328
|
+
async sendLocation(
|
|
1329
|
+
chatId: string,
|
|
1330
|
+
latitude: number,
|
|
1331
|
+
longitude: number,
|
|
1332
|
+
threadId?: string
|
|
1333
|
+
): Promise<string> {
|
|
1334
|
+
if (!this.bot || this._status !== 'connected') {
|
|
1335
|
+
throw new Error('Telegram bot is not connected');
|
|
1336
|
+
}
|
|
1337
|
+
|
|
1338
|
+
const options: Record<string, unknown> = {};
|
|
1339
|
+
if (threadId) {
|
|
1340
|
+
options.message_thread_id = parseInt(threadId, 10);
|
|
1341
|
+
}
|
|
1342
|
+
|
|
1343
|
+
const sent = await this.bot.api.sendLocation(chatId, latitude, longitude, options);
|
|
1344
|
+
return sent.message_id.toString();
|
|
1345
|
+
}
|
|
1346
|
+
|
|
1347
|
+
/**
|
|
1348
|
+
* Send a media group (album)
|
|
1349
|
+
*/
|
|
1350
|
+
async sendMediaGroup(
|
|
1351
|
+
chatId: string,
|
|
1352
|
+
media: Array<{ type: 'photo' | 'video'; filePath: string; caption?: string }>,
|
|
1353
|
+
threadId?: string
|
|
1354
|
+
): Promise<string[]> {
|
|
1355
|
+
if (!this.bot || this._status !== 'connected') {
|
|
1356
|
+
throw new Error('Telegram bot is not connected');
|
|
1357
|
+
}
|
|
1358
|
+
|
|
1359
|
+
const inputMedia = media.map((m, index) => {
|
|
1360
|
+
const fileBuffer = fs.readFileSync(m.filePath);
|
|
1361
|
+
const fileName = path.basename(m.filePath);
|
|
1362
|
+
return {
|
|
1363
|
+
type: m.type,
|
|
1364
|
+
media: new InputFile(fileBuffer, fileName),
|
|
1365
|
+
caption: index === 0 ? m.caption : undefined, // Caption on first item only
|
|
1366
|
+
};
|
|
1367
|
+
});
|
|
1368
|
+
|
|
1369
|
+
const options: Record<string, unknown> = {};
|
|
1370
|
+
if (threadId) {
|
|
1371
|
+
options.message_thread_id = parseInt(threadId, 10);
|
|
1372
|
+
}
|
|
1373
|
+
|
|
1374
|
+
const sent = await this.bot.api.sendMediaGroup(chatId, inputMedia as any, options);
|
|
1375
|
+
return sent.map(m => m.message_id.toString());
|
|
1376
|
+
}
|
|
1377
|
+
|
|
1378
|
+
// ============================================================================
|
|
1379
|
+
// Handler Registration
|
|
1380
|
+
// ============================================================================
|
|
1381
|
+
|
|
1382
|
+
/**
|
|
1383
|
+
* Register an error handler
|
|
1384
|
+
*/
|
|
1385
|
+
onError(handler: ErrorHandler): void {
|
|
1386
|
+
this.errorHandlers.push(handler);
|
|
1387
|
+
}
|
|
1388
|
+
|
|
1389
|
+
/**
|
|
1390
|
+
* Register a status change handler
|
|
1391
|
+
*/
|
|
1392
|
+
onStatusChange(handler: StatusHandler): void {
|
|
1393
|
+
this.statusHandlers.push(handler);
|
|
1394
|
+
}
|
|
1395
|
+
|
|
1396
|
+
/**
|
|
1397
|
+
* Get channel info
|
|
1398
|
+
*/
|
|
1399
|
+
async getInfo(): Promise<ChannelInfo> {
|
|
1400
|
+
let botId: string | undefined;
|
|
1401
|
+
let botDisplayName: string | undefined;
|
|
1402
|
+
|
|
1403
|
+
if (this.bot && this._status === 'connected') {
|
|
1404
|
+
try {
|
|
1405
|
+
const me = await this.bot.api.getMe();
|
|
1406
|
+
botId = me.id.toString();
|
|
1407
|
+
botDisplayName = me.first_name;
|
|
1408
|
+
this._botUsername = me.username;
|
|
1409
|
+
} catch {
|
|
1410
|
+
// Ignore errors getting info
|
|
1411
|
+
}
|
|
1412
|
+
}
|
|
1413
|
+
|
|
1414
|
+
return {
|
|
1415
|
+
type: 'telegram',
|
|
1416
|
+
status: this._status,
|
|
1417
|
+
botId,
|
|
1418
|
+
botUsername: this._botUsername,
|
|
1419
|
+
botDisplayName,
|
|
1420
|
+
};
|
|
1421
|
+
}
|
|
1422
|
+
|
|
1423
|
+
/**
|
|
1424
|
+
* Get webhook callback for Express/Fastify/etc.
|
|
1425
|
+
* Use this when running in webhook mode instead of polling.
|
|
1426
|
+
*/
|
|
1427
|
+
getWebhookCallback(): (req: Request, res: Response) => Promise<void> {
|
|
1428
|
+
if (!this.bot) {
|
|
1429
|
+
throw new Error('Bot not initialized');
|
|
1430
|
+
}
|
|
1431
|
+
return webhookCallback(this.bot, 'express') as unknown as (req: Request, res: Response) => Promise<void>;
|
|
1432
|
+
}
|
|
1433
|
+
|
|
1434
|
+
/**
|
|
1435
|
+
* Set webhook URL
|
|
1436
|
+
*/
|
|
1437
|
+
async setWebhook(url: string, secretToken?: string): Promise<void> {
|
|
1438
|
+
if (!this.bot) {
|
|
1439
|
+
throw new Error('Bot not initialized');
|
|
1440
|
+
}
|
|
1441
|
+
|
|
1442
|
+
await this.bot.api.setWebhook(url, {
|
|
1443
|
+
secret_token: secretToken,
|
|
1444
|
+
allowed_updates: ['message'] as const,
|
|
1445
|
+
});
|
|
1446
|
+
}
|
|
1447
|
+
|
|
1448
|
+
/**
|
|
1449
|
+
* Remove webhook
|
|
1450
|
+
*/
|
|
1451
|
+
async deleteWebhook(): Promise<void> {
|
|
1452
|
+
if (!this.bot) {
|
|
1453
|
+
throw new Error('Bot not initialized');
|
|
1454
|
+
}
|
|
1455
|
+
|
|
1456
|
+
await this.bot.api.deleteWebhook();
|
|
1457
|
+
}
|
|
1458
|
+
|
|
1459
|
+
/**
|
|
1460
|
+
* Start webhook server with health check endpoint
|
|
1461
|
+
* This creates an HTTP server that handles both webhook callbacks and health checks.
|
|
1462
|
+
*/
|
|
1463
|
+
async startWebhookServer(config: WebhookServerConfig): Promise<void> {
|
|
1464
|
+
if (this.webhookServer) {
|
|
1465
|
+
throw new Error('Webhook server is already running');
|
|
1466
|
+
}
|
|
1467
|
+
|
|
1468
|
+
if (!this.bot) {
|
|
1469
|
+
throw new Error('Bot not initialized. Call connect() first or initialize bot manually.');
|
|
1470
|
+
}
|
|
1471
|
+
|
|
1472
|
+
const {
|
|
1473
|
+
port,
|
|
1474
|
+
host = '0.0.0.0',
|
|
1475
|
+
secretToken,
|
|
1476
|
+
webhookPath = '/webhook',
|
|
1477
|
+
healthPath = '/healthz',
|
|
1478
|
+
} = config;
|
|
1479
|
+
|
|
1480
|
+
// Create HTTP server
|
|
1481
|
+
this.webhookServer = http.createServer(async (req, res) => {
|
|
1482
|
+
const url = req.url || '/';
|
|
1483
|
+
|
|
1484
|
+
// Health check endpoint
|
|
1485
|
+
if (req.method === 'GET' && url === healthPath) {
|
|
1486
|
+
await this.handleHealthCheck(req, res);
|
|
1487
|
+
return;
|
|
1488
|
+
}
|
|
1489
|
+
|
|
1490
|
+
// Webhook endpoint
|
|
1491
|
+
if (req.method === 'POST' && url === webhookPath) {
|
|
1492
|
+
// Validate secret token if configured
|
|
1493
|
+
if (secretToken) {
|
|
1494
|
+
const requestToken = req.headers['x-telegram-bot-api-secret-token'];
|
|
1495
|
+
if (requestToken !== secretToken) {
|
|
1496
|
+
res.writeHead(401, { 'Content-Type': 'application/json' });
|
|
1497
|
+
res.end(JSON.stringify({ error: 'Unauthorized' }));
|
|
1498
|
+
return;
|
|
1499
|
+
}
|
|
1500
|
+
}
|
|
1501
|
+
|
|
1502
|
+
// Handle webhook callback
|
|
1503
|
+
await this.handleWebhookRequest(req, res);
|
|
1504
|
+
return;
|
|
1505
|
+
}
|
|
1506
|
+
|
|
1507
|
+
// 404 for unknown routes
|
|
1508
|
+
res.writeHead(404, { 'Content-Type': 'application/json' });
|
|
1509
|
+
res.end(JSON.stringify({ error: 'Not found' }));
|
|
1510
|
+
});
|
|
1511
|
+
|
|
1512
|
+
// Start listening
|
|
1513
|
+
return new Promise((resolve, reject) => {
|
|
1514
|
+
this.webhookServer!.listen(port, host, () => {
|
|
1515
|
+
console.log(`Telegram webhook server listening on ${host}:${port}`);
|
|
1516
|
+
console.log(` Webhook endpoint: ${webhookPath}`);
|
|
1517
|
+
console.log(` Health check: ${healthPath}`);
|
|
1518
|
+
resolve();
|
|
1519
|
+
});
|
|
1520
|
+
|
|
1521
|
+
this.webhookServer!.on('error', (error) => {
|
|
1522
|
+
console.error('Webhook server error:', error);
|
|
1523
|
+
reject(error);
|
|
1524
|
+
});
|
|
1525
|
+
});
|
|
1526
|
+
}
|
|
1527
|
+
|
|
1528
|
+
/**
|
|
1529
|
+
* Stop the webhook server
|
|
1530
|
+
*/
|
|
1531
|
+
async stopWebhookServer(): Promise<void> {
|
|
1532
|
+
if (!this.webhookServer) {
|
|
1533
|
+
return;
|
|
1534
|
+
}
|
|
1535
|
+
|
|
1536
|
+
return new Promise((resolve) => {
|
|
1537
|
+
this.webhookServer!.close(() => {
|
|
1538
|
+
console.log('Webhook server stopped');
|
|
1539
|
+
this.webhookServer = undefined;
|
|
1540
|
+
resolve();
|
|
1541
|
+
});
|
|
1542
|
+
});
|
|
1543
|
+
}
|
|
1544
|
+
|
|
1545
|
+
/**
|
|
1546
|
+
* Handle health check requests
|
|
1547
|
+
*/
|
|
1548
|
+
private async handleHealthCheck(_req: http.IncomingMessage, res: http.ServerResponse): Promise<void> {
|
|
1549
|
+
const health = {
|
|
1550
|
+
status: this._status === 'connected' ? 'healthy' : 'unhealthy',
|
|
1551
|
+
timestamp: new Date().toISOString(),
|
|
1552
|
+
bot: {
|
|
1553
|
+
status: this._status,
|
|
1554
|
+
username: this._botUsername || null,
|
|
1555
|
+
connected: this._status === 'connected',
|
|
1556
|
+
},
|
|
1557
|
+
uptime: process.uptime(),
|
|
1558
|
+
memory: process.memoryUsage(),
|
|
1559
|
+
};
|
|
1560
|
+
|
|
1561
|
+
const statusCode = health.status === 'healthy' ? 200 : 503;
|
|
1562
|
+
res.writeHead(statusCode, { 'Content-Type': 'application/json' });
|
|
1563
|
+
res.end(JSON.stringify(health, null, 2));
|
|
1564
|
+
}
|
|
1565
|
+
|
|
1566
|
+
/**
|
|
1567
|
+
* Handle webhook requests
|
|
1568
|
+
*/
|
|
1569
|
+
private async handleWebhookRequest(req: http.IncomingMessage, res: http.ServerResponse): Promise<void> {
|
|
1570
|
+
let body = '';
|
|
1571
|
+
|
|
1572
|
+
req.on('data', (chunk) => {
|
|
1573
|
+
body += chunk.toString();
|
|
1574
|
+
});
|
|
1575
|
+
|
|
1576
|
+
req.on('end', async () => {
|
|
1577
|
+
try {
|
|
1578
|
+
const update = JSON.parse(body);
|
|
1579
|
+
|
|
1580
|
+
// Process the update using grammY's webhook handler
|
|
1581
|
+
if (this.bot) {
|
|
1582
|
+
await this.bot.handleUpdate(update);
|
|
1583
|
+
}
|
|
1584
|
+
|
|
1585
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1586
|
+
res.end(JSON.stringify({ ok: true }));
|
|
1587
|
+
} catch (error) {
|
|
1588
|
+
console.error('Error processing webhook:', error);
|
|
1589
|
+
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
1590
|
+
res.end(JSON.stringify({ error: 'Internal server error' }));
|
|
1591
|
+
}
|
|
1592
|
+
});
|
|
1593
|
+
}
|
|
1594
|
+
|
|
1595
|
+
/**
|
|
1596
|
+
* Connect using webhook mode instead of polling
|
|
1597
|
+
* Sets up the bot, registers commands, and starts the webhook server.
|
|
1598
|
+
*/
|
|
1599
|
+
async connectWithWebhook(webhookUrl: string, serverConfig: WebhookServerConfig): Promise<void> {
|
|
1600
|
+
if (this._status === 'connected' || this._status === 'connecting') {
|
|
1601
|
+
return;
|
|
1602
|
+
}
|
|
1603
|
+
|
|
1604
|
+
this.setStatus('connecting');
|
|
1605
|
+
this.resetBackoff();
|
|
1606
|
+
|
|
1607
|
+
try {
|
|
1608
|
+
// Create bot instance
|
|
1609
|
+
this.bot = new Bot(this.config.botToken);
|
|
1610
|
+
|
|
1611
|
+
// Add API throttling
|
|
1612
|
+
const throttler = apiThrottler();
|
|
1613
|
+
this.bot.api.config.use(throttler);
|
|
1614
|
+
|
|
1615
|
+
// Add sequential processing
|
|
1616
|
+
if (this.config.sequentialProcessingEnabled) {
|
|
1617
|
+
this.bot.use(sequentialize(this.getSequentialKey));
|
|
1618
|
+
}
|
|
1619
|
+
|
|
1620
|
+
// Get bot info
|
|
1621
|
+
const me = await this.bot.api.getMe();
|
|
1622
|
+
this._botUsername = me.username;
|
|
1623
|
+
|
|
1624
|
+
// Register bot commands
|
|
1625
|
+
await this.registerBotCommands();
|
|
1626
|
+
|
|
1627
|
+
// Set up message handler
|
|
1628
|
+
this.bot.on('message:text', async (ctx) => {
|
|
1629
|
+
await this.handleTextMessage(ctx);
|
|
1630
|
+
});
|
|
1631
|
+
|
|
1632
|
+
// Handle errors
|
|
1633
|
+
this.bot.catch(async (err) => {
|
|
1634
|
+
await this.handleBotError(err);
|
|
1635
|
+
});
|
|
1636
|
+
|
|
1637
|
+
// Start deduplication cleanup
|
|
1638
|
+
if (this.config.deduplicationEnabled) {
|
|
1639
|
+
this.startDedupCleanup();
|
|
1640
|
+
}
|
|
1641
|
+
|
|
1642
|
+
// Start webhook server
|
|
1643
|
+
await this.startWebhookServer(serverConfig);
|
|
1644
|
+
|
|
1645
|
+
// Set webhook URL with Telegram
|
|
1646
|
+
await this.setWebhook(webhookUrl, serverConfig.secretToken);
|
|
1647
|
+
|
|
1648
|
+
console.log(`Telegram bot @${this._botUsername} connected via webhook`);
|
|
1649
|
+
this.setStatus('connected');
|
|
1650
|
+
} catch (error) {
|
|
1651
|
+
const err = error instanceof Error ? error : new Error(String(error));
|
|
1652
|
+
this.setStatus('error', err);
|
|
1653
|
+
throw err;
|
|
1654
|
+
}
|
|
1655
|
+
}
|
|
1656
|
+
|
|
1657
|
+
// Private methods
|
|
1658
|
+
|
|
1659
|
+
private mapContextToMessage(ctx: Context, overrideText?: string): IncomingMessage {
|
|
1660
|
+
const msg = ctx.message!;
|
|
1661
|
+
const from = msg.from!;
|
|
1662
|
+
const chat = msg.chat;
|
|
1663
|
+
|
|
1664
|
+
// Check for forum topic (message_thread_id indicates a forum topic)
|
|
1665
|
+
const threadId = msg.message_thread_id?.toString();
|
|
1666
|
+
const isForumTopic = msg.is_topic_message === true || threadId !== undefined;
|
|
1667
|
+
|
|
1668
|
+
return {
|
|
1669
|
+
messageId: msg.message_id.toString(),
|
|
1670
|
+
channel: 'telegram',
|
|
1671
|
+
userId: from.id.toString(),
|
|
1672
|
+
userName: from.first_name + (from.last_name ? ` ${from.last_name}` : ''),
|
|
1673
|
+
chatId: chat.id.toString(),
|
|
1674
|
+
text: overrideText ?? msg.text ?? '',
|
|
1675
|
+
timestamp: new Date(msg.date * 1000),
|
|
1676
|
+
replyTo: msg.reply_to_message?.message_id.toString(),
|
|
1677
|
+
threadId,
|
|
1678
|
+
isForumTopic,
|
|
1679
|
+
raw: ctx,
|
|
1680
|
+
};
|
|
1681
|
+
}
|
|
1682
|
+
|
|
1683
|
+
private async handleIncomingMessage(message: IncomingMessage): Promise<void> {
|
|
1684
|
+
for (const handler of this.messageHandlers) {
|
|
1685
|
+
try {
|
|
1686
|
+
await handler(message);
|
|
1687
|
+
} catch (error) {
|
|
1688
|
+
console.error('Error in message handler:', error);
|
|
1689
|
+
this.handleError(
|
|
1690
|
+
error instanceof Error ? error : new Error(String(error)),
|
|
1691
|
+
'messageHandler'
|
|
1692
|
+
);
|
|
1693
|
+
}
|
|
1694
|
+
}
|
|
1695
|
+
}
|
|
1696
|
+
|
|
1697
|
+
private handleError(error: Error, context?: string): void {
|
|
1698
|
+
for (const handler of this.errorHandlers) {
|
|
1699
|
+
try {
|
|
1700
|
+
handler(error, context);
|
|
1701
|
+
} catch (e) {
|
|
1702
|
+
console.error('Error in error handler:', e);
|
|
1703
|
+
}
|
|
1704
|
+
}
|
|
1705
|
+
}
|
|
1706
|
+
|
|
1707
|
+
private setStatus(status: ChannelStatus, error?: Error): void {
|
|
1708
|
+
this._status = status;
|
|
1709
|
+
for (const handler of this.statusHandlers) {
|
|
1710
|
+
try {
|
|
1711
|
+
handler(status, error);
|
|
1712
|
+
} catch (e) {
|
|
1713
|
+
console.error('Error in status handler:', e);
|
|
1714
|
+
}
|
|
1715
|
+
}
|
|
1716
|
+
}
|
|
1717
|
+
}
|
|
1718
|
+
|
|
1719
|
+
/**
|
|
1720
|
+
* Create a Telegram adapter from configuration
|
|
1721
|
+
*/
|
|
1722
|
+
export function createTelegramAdapter(config: TelegramAdapterConfig): TelegramAdapter {
|
|
1723
|
+
if (!config.botToken) {
|
|
1724
|
+
throw new Error('Telegram bot token is required');
|
|
1725
|
+
}
|
|
1726
|
+
return new TelegramAdapter(config);
|
|
1727
|
+
}
|