machinaos 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env.template +71 -0
- package/LICENSE +21 -0
- package/README.md +87 -0
- package/bin/cli.js +159 -0
- package/client/.dockerignore +45 -0
- package/client/Dockerfile +68 -0
- package/client/eslint.config.js +29 -0
- package/client/index.html +13 -0
- package/client/nginx.conf +66 -0
- package/client/package.json +48 -0
- package/client/src/App.tsx +27 -0
- package/client/src/Dashboard.tsx +1173 -0
- package/client/src/ParameterPanel.tsx +301 -0
- package/client/src/components/AIAgentNode.tsx +321 -0
- package/client/src/components/APIKeyValidator.tsx +118 -0
- package/client/src/components/ClaudeChatModelNode.tsx +18 -0
- package/client/src/components/ConditionalEdge.tsx +189 -0
- package/client/src/components/CredentialsModal.tsx +306 -0
- package/client/src/components/EdgeConditionEditor.tsx +443 -0
- package/client/src/components/GeminiChatModelNode.tsx +18 -0
- package/client/src/components/GenericNode.tsx +357 -0
- package/client/src/components/LocationParameterPanel.tsx +154 -0
- package/client/src/components/ModelNode.tsx +286 -0
- package/client/src/components/OpenAIChatModelNode.tsx +18 -0
- package/client/src/components/OutputPanel.tsx +471 -0
- package/client/src/components/ParameterRenderer.tsx +1874 -0
- package/client/src/components/SkillEditorModal.tsx +417 -0
- package/client/src/components/SquareNode.tsx +797 -0
- package/client/src/components/StartNode.tsx +250 -0
- package/client/src/components/ToolkitNode.tsx +365 -0
- package/client/src/components/TriggerNode.tsx +463 -0
- package/client/src/components/auth/LoginPage.tsx +247 -0
- package/client/src/components/auth/ProtectedRoute.tsx +59 -0
- package/client/src/components/base/BaseChatModelNode.tsx +271 -0
- package/client/src/components/icons/AIProviderIcons.tsx +50 -0
- package/client/src/components/maps/GoogleMapsPicker.tsx +137 -0
- package/client/src/components/maps/MapsPreviewPanel.tsx +110 -0
- package/client/src/components/maps/index.ts +26 -0
- package/client/src/components/parameterPanel/InputSection.tsx +1094 -0
- package/client/src/components/parameterPanel/LocationPanelLayout.tsx +65 -0
- package/client/src/components/parameterPanel/MapsSection.tsx +92 -0
- package/client/src/components/parameterPanel/MiddleSection.tsx +571 -0
- package/client/src/components/parameterPanel/OutputSection.tsx +81 -0
- package/client/src/components/parameterPanel/ParameterPanelLayout.tsx +82 -0
- package/client/src/components/parameterPanel/ToolSchemaEditor.tsx +436 -0
- package/client/src/components/parameterPanel/index.ts +42 -0
- package/client/src/components/shared/DataPanel.tsx +142 -0
- package/client/src/components/shared/JSONTreeRenderer.tsx +106 -0
- package/client/src/components/ui/AIResultModal.tsx +204 -0
- package/client/src/components/ui/AndroidSettingsPanel.tsx +401 -0
- package/client/src/components/ui/CodeEditor.tsx +81 -0
- package/client/src/components/ui/CollapsibleSection.tsx +88 -0
- package/client/src/components/ui/ComponentItem.tsx +154 -0
- package/client/src/components/ui/ComponentPalette.tsx +321 -0
- package/client/src/components/ui/ConsolePanel.tsx +1074 -0
- package/client/src/components/ui/ErrorBoundary.tsx +196 -0
- package/client/src/components/ui/InputNodesPanel.tsx +204 -0
- package/client/src/components/ui/MapSelector.tsx +314 -0
- package/client/src/components/ui/Modal.tsx +149 -0
- package/client/src/components/ui/NodeContextMenu.tsx +192 -0
- package/client/src/components/ui/NodeOutputPanel.tsx +1150 -0
- package/client/src/components/ui/OutputDisplayPanel.tsx +381 -0
- package/client/src/components/ui/SettingsPanel.tsx +243 -0
- package/client/src/components/ui/TopToolbar.tsx +736 -0
- package/client/src/components/ui/WhatsAppSettingsPanel.tsx +345 -0
- package/client/src/components/ui/WorkflowSidebar.tsx +294 -0
- package/client/src/config/antdTheme.ts +186 -0
- package/client/src/config/api.ts +54 -0
- package/client/src/contexts/AuthContext.tsx +221 -0
- package/client/src/contexts/ThemeContext.tsx +42 -0
- package/client/src/contexts/WebSocketContext.tsx +1971 -0
- package/client/src/factories/baseChatModelFactory.ts +256 -0
- package/client/src/hooks/useAndroidOperations.ts +164 -0
- package/client/src/hooks/useApiKeyValidation.ts +107 -0
- package/client/src/hooks/useApiKeys.ts +238 -0
- package/client/src/hooks/useAppTheme.ts +17 -0
- package/client/src/hooks/useComponentPalette.ts +51 -0
- package/client/src/hooks/useCopyPaste.ts +155 -0
- package/client/src/hooks/useDragAndDrop.ts +124 -0
- package/client/src/hooks/useDragVariable.ts +88 -0
- package/client/src/hooks/useExecution.ts +313 -0
- package/client/src/hooks/useParameterPanel.ts +176 -0
- package/client/src/hooks/useReactFlowNodes.ts +189 -0
- package/client/src/hooks/useToolSchema.ts +209 -0
- package/client/src/hooks/useWhatsApp.ts +196 -0
- package/client/src/hooks/useWorkflowManagement.ts +46 -0
- package/client/src/index.css +315 -0
- package/client/src/main.tsx +19 -0
- package/client/src/nodeDefinitions/aiAgentNodes.ts +336 -0
- package/client/src/nodeDefinitions/aiModelNodes.ts +340 -0
- package/client/src/nodeDefinitions/androidDeviceNodes.ts +140 -0
- package/client/src/nodeDefinitions/androidServiceNodes.ts +383 -0
- package/client/src/nodeDefinitions/chatNodes.ts +135 -0
- package/client/src/nodeDefinitions/codeNodes.ts +54 -0
- package/client/src/nodeDefinitions/documentNodes.ts +379 -0
- package/client/src/nodeDefinitions/index.ts +15 -0
- package/client/src/nodeDefinitions/locationNodes.ts +463 -0
- package/client/src/nodeDefinitions/schedulerNodes.ts +220 -0
- package/client/src/nodeDefinitions/skillNodes.ts +211 -0
- package/client/src/nodeDefinitions/toolNodes.ts +198 -0
- package/client/src/nodeDefinitions/utilityNodes.ts +284 -0
- package/client/src/nodeDefinitions/whatsappNodes.ts +865 -0
- package/client/src/nodeDefinitions/workflowNodes.ts +41 -0
- package/client/src/nodeDefinitions.ts +104 -0
- package/client/src/schemas/workflowSchema.ts +264 -0
- package/client/src/services/dynamicParameterService.ts +96 -0
- package/client/src/services/execution/aiAgentExecutionService.ts +35 -0
- package/client/src/services/executionService.ts +232 -0
- package/client/src/services/workflowApi.ts +91 -0
- package/client/src/store/useAppStore.ts +582 -0
- package/client/src/styles/theme.ts +508 -0
- package/client/src/styles/zIndex.ts +17 -0
- package/client/src/types/ComponentTypes.ts +39 -0
- package/client/src/types/EdgeCondition.ts +231 -0
- package/client/src/types/INodeProperties.ts +288 -0
- package/client/src/types/NodeTypes.ts +28 -0
- package/client/src/utils/formatters.ts +33 -0
- package/client/src/utils/googleMapsLoader.ts +140 -0
- package/client/src/utils/locationUtils.ts +85 -0
- package/client/src/utils/nodeUtils.ts +31 -0
- package/client/src/utils/workflow.ts +30 -0
- package/client/src/utils/workflowExport.ts +120 -0
- package/client/src/vite-env.d.ts +12 -0
- package/client/tailwind.config.js +60 -0
- package/client/tsconfig.json +25 -0
- package/client/tsconfig.node.json +11 -0
- package/client/vite.config.js +35 -0
- package/docker-compose.prod.yml +107 -0
- package/docker-compose.yml +104 -0
- package/docs-MachinaOs/README.md +85 -0
- package/docs-MachinaOs/deployment/docker.mdx +228 -0
- package/docs-MachinaOs/deployment/production.mdx +345 -0
- package/docs-MachinaOs/docs.json +75 -0
- package/docs-MachinaOs/faq.mdx +309 -0
- package/docs-MachinaOs/favicon.svg +5 -0
- package/docs-MachinaOs/installation.mdx +160 -0
- package/docs-MachinaOs/introduction.mdx +114 -0
- package/docs-MachinaOs/logo/dark.svg +6 -0
- package/docs-MachinaOs/logo/light.svg +6 -0
- package/docs-MachinaOs/nodes/ai-agent.mdx +216 -0
- package/docs-MachinaOs/nodes/ai-models.mdx +240 -0
- package/docs-MachinaOs/nodes/android.mdx +411 -0
- package/docs-MachinaOs/nodes/overview.mdx +181 -0
- package/docs-MachinaOs/nodes/schedulers.mdx +316 -0
- package/docs-MachinaOs/nodes/webhooks.mdx +330 -0
- package/docs-MachinaOs/nodes/whatsapp.mdx +305 -0
- package/docs-MachinaOs/quickstart.mdx +119 -0
- package/docs-MachinaOs/tutorials/ai-agent-workflow.mdx +177 -0
- package/docs-MachinaOs/tutorials/android-automation.mdx +242 -0
- package/docs-MachinaOs/tutorials/first-workflow.mdx +134 -0
- package/docs-MachinaOs/tutorials/whatsapp-automation.mdx +185 -0
- package/nul +0 -0
- package/package.json +70 -0
- package/scripts/build.js +158 -0
- package/scripts/check-ports.ps1 +33 -0
- package/scripts/clean.js +40 -0
- package/scripts/docker.js +93 -0
- package/scripts/kill-port.ps1 +154 -0
- package/scripts/start.js +210 -0
- package/scripts/stop.js +325 -0
- package/server/.dockerignore +44 -0
- package/server/Dockerfile +45 -0
- package/server/constants.py +249 -0
- package/server/core/__init__.py +1 -0
- package/server/core/cache.py +461 -0
- package/server/core/config.py +128 -0
- package/server/core/container.py +99 -0
- package/server/core/database.py +1211 -0
- package/server/core/logging.py +314 -0
- package/server/main.py +289 -0
- package/server/middleware/__init__.py +5 -0
- package/server/middleware/auth.py +89 -0
- package/server/models/__init__.py +1 -0
- package/server/models/auth.py +52 -0
- package/server/models/cache.py +24 -0
- package/server/models/database.py +211 -0
- package/server/models/nodes.py +455 -0
- package/server/package.json +9 -0
- package/server/pyproject.toml +72 -0
- package/server/requirements.txt +83 -0
- package/server/routers/__init__.py +1 -0
- package/server/routers/android.py +294 -0
- package/server/routers/auth.py +203 -0
- package/server/routers/database.py +151 -0
- package/server/routers/maps.py +142 -0
- package/server/routers/nodejs_compat.py +289 -0
- package/server/routers/webhook.py +90 -0
- package/server/routers/websocket.py +2127 -0
- package/server/routers/whatsapp.py +761 -0
- package/server/routers/workflow.py +200 -0
- package/server/services/__init__.py +1 -0
- package/server/services/ai.py +2415 -0
- package/server/services/android/__init__.py +27 -0
- package/server/services/android/broadcaster.py +114 -0
- package/server/services/android/client.py +608 -0
- package/server/services/android/manager.py +78 -0
- package/server/services/android/protocol.py +165 -0
- package/server/services/android_service.py +588 -0
- package/server/services/auth.py +131 -0
- package/server/services/chat_client.py +160 -0
- package/server/services/deployment/__init__.py +12 -0
- package/server/services/deployment/manager.py +706 -0
- package/server/services/deployment/state.py +47 -0
- package/server/services/deployment/triggers.py +275 -0
- package/server/services/event_waiter.py +785 -0
- package/server/services/execution/__init__.py +77 -0
- package/server/services/execution/cache.py +769 -0
- package/server/services/execution/conditions.py +373 -0
- package/server/services/execution/dlq.py +132 -0
- package/server/services/execution/executor.py +1351 -0
- package/server/services/execution/models.py +531 -0
- package/server/services/execution/recovery.py +235 -0
- package/server/services/handlers/__init__.py +126 -0
- package/server/services/handlers/ai.py +355 -0
- package/server/services/handlers/android.py +260 -0
- package/server/services/handlers/code.py +278 -0
- package/server/services/handlers/document.py +598 -0
- package/server/services/handlers/http.py +193 -0
- package/server/services/handlers/polyglot.py +105 -0
- package/server/services/handlers/tools.py +845 -0
- package/server/services/handlers/triggers.py +107 -0
- package/server/services/handlers/utility.py +822 -0
- package/server/services/handlers/whatsapp.py +476 -0
- package/server/services/maps.py +289 -0
- package/server/services/memory_store.py +103 -0
- package/server/services/node_executor.py +375 -0
- package/server/services/parameter_resolver.py +218 -0
- package/server/services/polyglot_client.py +169 -0
- package/server/services/scheduler.py +155 -0
- package/server/services/skill_loader.py +417 -0
- package/server/services/status_broadcaster.py +826 -0
- package/server/services/temporal/__init__.py +23 -0
- package/server/services/temporal/activities.py +344 -0
- package/server/services/temporal/client.py +76 -0
- package/server/services/temporal/executor.py +147 -0
- package/server/services/temporal/worker.py +251 -0
- package/server/services/temporal/workflow.py +355 -0
- package/server/services/temporal/ws_client.py +236 -0
- package/server/services/text.py +111 -0
- package/server/services/user_auth.py +172 -0
- package/server/services/websocket_client.py +29 -0
- package/server/services/workflow.py +597 -0
- package/server/skills/android-skill/SKILL.md +82 -0
- package/server/skills/assistant-personality/SKILL.md +45 -0
- package/server/skills/code-skill/SKILL.md +140 -0
- package/server/skills/http-skill/SKILL.md +161 -0
- package/server/skills/maps-skill/SKILL.md +170 -0
- package/server/skills/memory-skill/SKILL.md +154 -0
- package/server/skills/scheduler-skill/SKILL.md +84 -0
- package/server/skills/whatsapp-skill/SKILL.md +283 -0
- package/server/uv.lock +2916 -0
- package/server/whatsapp-rpc/.dockerignore +30 -0
- package/server/whatsapp-rpc/Dockerfile +44 -0
- package/server/whatsapp-rpc/Dockerfile.web +17 -0
- package/server/whatsapp-rpc/README.md +139 -0
- package/server/whatsapp-rpc/cli.js +95 -0
- package/server/whatsapp-rpc/configs/config.yaml +7 -0
- package/server/whatsapp-rpc/docker-compose.yml +35 -0
- package/server/whatsapp-rpc/docs/API.md +410 -0
- package/server/whatsapp-rpc/go.mod +67 -0
- package/server/whatsapp-rpc/go.sum +203 -0
- package/server/whatsapp-rpc/package.json +30 -0
- package/server/whatsapp-rpc/schema.json +1294 -0
- package/server/whatsapp-rpc/scripts/clean.cjs +66 -0
- package/server/whatsapp-rpc/scripts/cli.js +162 -0
- package/server/whatsapp-rpc/src/go/cmd/server/main.go +91 -0
- package/server/whatsapp-rpc/src/go/config/config.go +49 -0
- package/server/whatsapp-rpc/src/go/rpc/rpc.go +446 -0
- package/server/whatsapp-rpc/src/go/rpc/server.go +112 -0
- package/server/whatsapp-rpc/src/go/whatsapp/history.go +166 -0
- package/server/whatsapp-rpc/src/go/whatsapp/messages.go +390 -0
- package/server/whatsapp-rpc/src/go/whatsapp/service.go +2130 -0
- package/server/whatsapp-rpc/src/go/whatsapp/types.go +261 -0
- package/server/whatsapp-rpc/src/python/pyproject.toml +15 -0
- package/server/whatsapp-rpc/src/python/whatsapp_rpc/__init__.py +4 -0
- package/server/whatsapp-rpc/src/python/whatsapp_rpc/client.py +427 -0
- package/server/whatsapp-rpc/web/app.py +609 -0
- package/server/whatsapp-rpc/web/requirements.txt +6 -0
- package/server/whatsapp-rpc/web/rpc_client.py +427 -0
- package/server/whatsapp-rpc/web/static/openapi.yaml +59 -0
- package/server/whatsapp-rpc/web/templates/base.html +150 -0
- package/server/whatsapp-rpc/web/templates/contacts.html +240 -0
- package/server/whatsapp-rpc/web/templates/dashboard.html +320 -0
- package/server/whatsapp-rpc/web/templates/groups.html +328 -0
- package/server/whatsapp-rpc/web/templates/messages.html +465 -0
- package/server/whatsapp-rpc/web/templates/messaging.html +681 -0
- package/server/whatsapp-rpc/web/templates/send.html +259 -0
- package/server/whatsapp-rpc/web/templates/settings.html +459 -0
|
@@ -0,0 +1,2130 @@
|
|
|
1
|
+
package whatsapp
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"context"
|
|
5
|
+
"encoding/json"
|
|
6
|
+
"fmt"
|
|
7
|
+
"math/rand"
|
|
8
|
+
"os"
|
|
9
|
+
"path/filepath"
|
|
10
|
+
"strings"
|
|
11
|
+
"sync"
|
|
12
|
+
"time"
|
|
13
|
+
|
|
14
|
+
"github.com/sirupsen/logrus"
|
|
15
|
+
"github.com/skip2/go-qrcode"
|
|
16
|
+
"go.mau.fi/whatsmeow"
|
|
17
|
+
"go.mau.fi/whatsmeow/proto/waCompanionReg"
|
|
18
|
+
"go.mau.fi/whatsmeow/proto/waE2E"
|
|
19
|
+
"go.mau.fi/whatsmeow/store"
|
|
20
|
+
"go.mau.fi/whatsmeow/store/sqlstore"
|
|
21
|
+
"go.mau.fi/whatsmeow/types"
|
|
22
|
+
"go.mau.fi/whatsmeow/types/events"
|
|
23
|
+
waLog "go.mau.fi/whatsmeow/util/log"
|
|
24
|
+
_ "modernc.org/sqlite"
|
|
25
|
+
|
|
26
|
+
"whatsapp-rpc/src/go/config"
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
type Service struct {
|
|
30
|
+
mu sync.Mutex
|
|
31
|
+
client *whatsmeow.Client
|
|
32
|
+
container *sqlstore.Container // Database container for proper cleanup
|
|
33
|
+
dbPath string // Database file path
|
|
34
|
+
logger *logrus.Logger
|
|
35
|
+
events chan Event
|
|
36
|
+
running bool
|
|
37
|
+
pairing bool
|
|
38
|
+
shutdown bool
|
|
39
|
+
lastQRCode *QRCodeData
|
|
40
|
+
lastQRCodeTime time.Time
|
|
41
|
+
messages map[string]*events.Message // Store messages by ID for media download
|
|
42
|
+
messageOrder []string // Track message insertion order for FIFO cleanup
|
|
43
|
+
historyStore *HistoryStore // Persistent message history store
|
|
44
|
+
|
|
45
|
+
// Rate limiting for anti-ban protection
|
|
46
|
+
rateMu sync.Mutex
|
|
47
|
+
rateLimitConfig *RateLimitConfig
|
|
48
|
+
rateLimitStats *RateLimitStats
|
|
49
|
+
messageTimes []time.Time // Sliding window of message times
|
|
50
|
+
contactsSeen map[string]bool // Track known contacts
|
|
51
|
+
newContactsToday map[string]time.Time // New contacts with timestamp
|
|
52
|
+
dailyResetTime time.Time // When daily counters were last reset
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
func NewService(dbConfig config.DatabaseConfig, logger *logrus.Logger) (*Service, error) {
|
|
58
|
+
// Configure device to appear as legitimate WhatsApp Web on Chrome
|
|
59
|
+
// Uses default WhatsApp Web version and Chrome platform type for natural appearance
|
|
60
|
+
store.DeviceProps.PlatformType = waCompanionReg.DeviceProps_CHROME.Enum()
|
|
61
|
+
store.SetOSInfo("Windows - Zeenie", store.GetWAVersion())
|
|
62
|
+
|
|
63
|
+
// Ensure database directory exists
|
|
64
|
+
dbDir := filepath.Dir(dbConfig.Path)
|
|
65
|
+
if dbDir != "" && dbDir != "." {
|
|
66
|
+
if err := os.MkdirAll(dbDir, 0755); err != nil {
|
|
67
|
+
return nil, fmt.Errorf("failed to create database directory: %w", err)
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Setup database with WAL mode for concurrent access and extended busy timeout
|
|
72
|
+
dbLog := waLog.Stdout("Database", "INFO", true)
|
|
73
|
+
ctx := context.Background()
|
|
74
|
+
// Increased busy timeout to 30 seconds and added cache=shared for better concurrency on Windows
|
|
75
|
+
container, err := sqlstore.New(ctx, "sqlite", "file:"+dbConfig.Path+"?_pragma=foreign_keys(1)&_journal_mode=WAL&_busy_timeout=30000&cache=shared", dbLog)
|
|
76
|
+
if err != nil {
|
|
77
|
+
return nil, fmt.Errorf("failed to initialize database: %w", err)
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Get device store
|
|
81
|
+
deviceStore, err := container.GetFirstDevice(ctx)
|
|
82
|
+
if err != nil {
|
|
83
|
+
return nil, fmt.Errorf("failed to get device: %w", err)
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Create client - NO extra configuration like working example
|
|
87
|
+
clientLog := waLog.Stdout("WhatsApp", "INFO", true)
|
|
88
|
+
client := whatsmeow.NewClient(deviceStore, clientLog)
|
|
89
|
+
|
|
90
|
+
service := &Service{
|
|
91
|
+
client: client,
|
|
92
|
+
container: container,
|
|
93
|
+
dbPath: dbConfig.Path,
|
|
94
|
+
logger: logger,
|
|
95
|
+
events: make(chan Event, 100),
|
|
96
|
+
running: false,
|
|
97
|
+
pairing: false,
|
|
98
|
+
shutdown: false,
|
|
99
|
+
messages: make(map[string]*events.Message),
|
|
100
|
+
messageOrder: make([]string, 0, 100),
|
|
101
|
+
rateLimitConfig: DefaultRateLimitConfig(),
|
|
102
|
+
rateLimitStats: &RateLimitStats{},
|
|
103
|
+
messageTimes: make([]time.Time, 0, 100),
|
|
104
|
+
contactsSeen: make(map[string]bool),
|
|
105
|
+
newContactsToday: make(map[string]time.Time),
|
|
106
|
+
dailyResetTime: time.Now(),
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// Initialize history store for message persistence
|
|
110
|
+
historyStore, err := NewHistoryStore(dbConfig.Path, logger)
|
|
111
|
+
if err != nil {
|
|
112
|
+
logger.Warnf("Failed to init history store (history disabled): %v", err)
|
|
113
|
+
} else {
|
|
114
|
+
service.historyStore = historyStore
|
|
115
|
+
logger.Info("History store initialized")
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// Reset any previous connection state on service creation
|
|
119
|
+
if client.IsConnected() {
|
|
120
|
+
client.Disconnect()
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// Add event handler
|
|
124
|
+
client.AddEventHandler(service.eventHandler)
|
|
125
|
+
|
|
126
|
+
return service, nil
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// HasExistingSession returns true if there's a stored session that can be used to auto-connect
|
|
130
|
+
func (s *Service) HasExistingSession() bool {
|
|
131
|
+
hasClient := s.client != nil
|
|
132
|
+
hasStore := hasClient && s.client.Store != nil
|
|
133
|
+
hasID := hasStore && s.client.Store.ID != nil
|
|
134
|
+
|
|
135
|
+
s.logger.Infof("HasExistingSession check: client=%v, store=%v, id=%v", hasClient, hasStore, hasID)
|
|
136
|
+
|
|
137
|
+
return hasID
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
func (s *Service) safeEventSend(event Event) {
|
|
141
|
+
if s.shutdown {
|
|
142
|
+
return
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
defer func() {
|
|
146
|
+
if r := recover(); r != nil {
|
|
147
|
+
s.logger.Errorf("Event send panic recovered: %v", r)
|
|
148
|
+
}
|
|
149
|
+
}()
|
|
150
|
+
|
|
151
|
+
select {
|
|
152
|
+
case s.events <- event:
|
|
153
|
+
// Event sent successfully
|
|
154
|
+
default:
|
|
155
|
+
if s.shutdown {
|
|
156
|
+
s.logger.Info("Service is shutdown, skipping event")
|
|
157
|
+
} else {
|
|
158
|
+
s.logger.Warn("Events channel blocked, skipping event")
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
func (s *Service) Start() error {
|
|
164
|
+
s.mu.Lock()
|
|
165
|
+
defer s.mu.Unlock()
|
|
166
|
+
|
|
167
|
+
if s.running {
|
|
168
|
+
s.logger.Warn("Service already running")
|
|
169
|
+
return nil
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// Events channel is kept open across restarts (not closed in Shutdown)
|
|
173
|
+
// This allows existing WebSocket ForwardEvents goroutines to continue working
|
|
174
|
+
|
|
175
|
+
s.shutdown = false
|
|
176
|
+
s.running = true
|
|
177
|
+
|
|
178
|
+
s.logger.Info("Starting WhatsApp service...")
|
|
179
|
+
|
|
180
|
+
// Simple flow like working example
|
|
181
|
+
if s.client.Store.ID == nil {
|
|
182
|
+
s.logger.Info("No existing session found, starting pairing")
|
|
183
|
+
// Run pairing synchronously like working example
|
|
184
|
+
go func() {
|
|
185
|
+
if err := s.startPairing(); err != nil {
|
|
186
|
+
s.logger.Errorf("Pairing failed: %v", err)
|
|
187
|
+
}
|
|
188
|
+
}()
|
|
189
|
+
return nil
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// Connect with existing session
|
|
193
|
+
s.logger.Info("Existing session found, connecting...")
|
|
194
|
+
err := s.client.Connect()
|
|
195
|
+
if err != nil {
|
|
196
|
+
s.logger.Errorf("Failed to connect: %v", err)
|
|
197
|
+
return err
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
s.logger.Info("Connected successfully")
|
|
201
|
+
return nil
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
func (s *Service) startPairing() error {
|
|
205
|
+
s.logger.Info("Starting pairing process")
|
|
206
|
+
s.pairing = true
|
|
207
|
+
|
|
208
|
+
// Get QR channel - use simple context like working example
|
|
209
|
+
qrChan, err := s.client.GetQRChannel(context.Background())
|
|
210
|
+
if err != nil {
|
|
211
|
+
s.logger.Errorf("Failed to get QR channel: %v", err)
|
|
212
|
+
s.pairing = false
|
|
213
|
+
return err
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// Connect to WhatsApp
|
|
217
|
+
err = s.client.Connect()
|
|
218
|
+
if err != nil {
|
|
219
|
+
s.logger.Errorf("Failed to connect: %v", err)
|
|
220
|
+
s.pairing = false
|
|
221
|
+
return err
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// Process QR events - simple loop like working example
|
|
225
|
+
for evt := range qrChan {
|
|
226
|
+
if evt.Event == "code" {
|
|
227
|
+
s.logger.Info("QR code received, generating PNG")
|
|
228
|
+
qrData := s.handleQRCode(evt.Code)
|
|
229
|
+
|
|
230
|
+
s.safeEventSend(Event{
|
|
231
|
+
Type: "qr_code",
|
|
232
|
+
Data: map[string]interface{}{
|
|
233
|
+
"code": qrData.Code,
|
|
234
|
+
"filename": qrData.Filename,
|
|
235
|
+
},
|
|
236
|
+
Time: time.Now(),
|
|
237
|
+
})
|
|
238
|
+
} else if evt.Event == "success" {
|
|
239
|
+
s.logger.Info("Pairing successful")
|
|
240
|
+
s.pairing = false
|
|
241
|
+
s.safeEventSend(Event{
|
|
242
|
+
Type: "connected",
|
|
243
|
+
Data: map[string]interface{}{"status": "connected"},
|
|
244
|
+
Time: time.Now(),
|
|
245
|
+
})
|
|
246
|
+
return nil
|
|
247
|
+
} else {
|
|
248
|
+
s.logger.Infof("QR event: %s", evt.Event)
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
s.pairing = false
|
|
253
|
+
return fmt.Errorf("QR channel closed")
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
func (s *Service) handleQRCode(code string) QRCodeData {
|
|
257
|
+
// Delete old QR code files before creating new one
|
|
258
|
+
s.cleanupOldQRCodes()
|
|
259
|
+
|
|
260
|
+
// Ensure data/qr directory exists
|
|
261
|
+
qrDir := "data/qr"
|
|
262
|
+
os.MkdirAll(qrDir, 0755)
|
|
263
|
+
|
|
264
|
+
filename := filepath.Join(qrDir, fmt.Sprintf("qr_%d.png", time.Now().Unix()))
|
|
265
|
+
|
|
266
|
+
s.logger.Infof("Generating QR code PNG file: %s", filename)
|
|
267
|
+
s.logger.Debugf("QR code content (first 50 chars): %s...", code[:min(50, len(code))])
|
|
268
|
+
|
|
269
|
+
// Save QR code as PNG
|
|
270
|
+
err := qrcode.WriteFile(code, qrcode.Medium, 256, filename)
|
|
271
|
+
if err != nil {
|
|
272
|
+
s.logger.Errorf("Failed to save QR code to file %s: %v", filename, err)
|
|
273
|
+
} else {
|
|
274
|
+
s.logger.Infof("QR code successfully saved to %s", filename)
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
qrData := QRCodeData{
|
|
278
|
+
Code: code,
|
|
279
|
+
Filename: filename,
|
|
280
|
+
Time: time.Now(),
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
// Store QR code for API retrieval
|
|
284
|
+
s.mu.Lock()
|
|
285
|
+
s.lastQRCode = &qrData
|
|
286
|
+
s.lastQRCodeTime = time.Now()
|
|
287
|
+
s.mu.Unlock()
|
|
288
|
+
|
|
289
|
+
s.logger.Info("QR code ready for scanning. Instructions:")
|
|
290
|
+
s.logger.Info("1. Open WhatsApp on your phone")
|
|
291
|
+
s.logger.Info("2. Go to Settings > Linked Devices")
|
|
292
|
+
s.logger.Info("3. Tap 'Link a Device'")
|
|
293
|
+
s.logger.Info("4. Scan the QR code from the generated PNG file or terminal")
|
|
294
|
+
s.logger.Infof("5. QR code file location: %s", filename)
|
|
295
|
+
|
|
296
|
+
return qrData
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
func (s *Service) eventHandler(evt interface{}) {
|
|
301
|
+
if s.shutdown {
|
|
302
|
+
return
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
switch v := evt.(type) {
|
|
306
|
+
case *events.Connected:
|
|
307
|
+
s.logger.Infof("Successfully connected to WhatsApp! Device ID: %s", s.client.Store.ID.String())
|
|
308
|
+
s.pairing = false
|
|
309
|
+
|
|
310
|
+
// Generate groups JSON file asynchronously
|
|
311
|
+
go func() {
|
|
312
|
+
time.Sleep(2 * time.Second) // Wait for connection to stabilize
|
|
313
|
+
if err := s.generateGroupsJSON(); err != nil {
|
|
314
|
+
s.logger.Errorf("Failed to generate groups.json: %v", err)
|
|
315
|
+
}
|
|
316
|
+
}()
|
|
317
|
+
|
|
318
|
+
s.safeEventSend(Event{
|
|
319
|
+
Type: "connected",
|
|
320
|
+
Data: map[string]interface{}{
|
|
321
|
+
"status": "connected",
|
|
322
|
+
"device_id": s.client.Store.ID.String(),
|
|
323
|
+
},
|
|
324
|
+
Time: time.Now(),
|
|
325
|
+
})
|
|
326
|
+
case *events.Disconnected:
|
|
327
|
+
s.logger.Warnf("Disconnected from WhatsApp. Reason: %+v", v)
|
|
328
|
+
s.safeEventSend(Event{
|
|
329
|
+
Type: "disconnected",
|
|
330
|
+
Data: map[string]interface{}{
|
|
331
|
+
"status": "disconnected",
|
|
332
|
+
"reason": fmt.Sprintf("%+v", v),
|
|
333
|
+
},
|
|
334
|
+
Time: time.Now(),
|
|
335
|
+
})
|
|
336
|
+
case *events.ConnectFailure:
|
|
337
|
+
s.logger.Errorf("Connection failure: %+v", v)
|
|
338
|
+
s.safeEventSend(Event{
|
|
339
|
+
Type: "connection_failure",
|
|
340
|
+
Data: map[string]interface{}{
|
|
341
|
+
"error": fmt.Sprintf("%+v", v),
|
|
342
|
+
"reason": v.Reason.String(),
|
|
343
|
+
},
|
|
344
|
+
Time: time.Now(),
|
|
345
|
+
})
|
|
346
|
+
case *events.LoggedOut:
|
|
347
|
+
s.logger.Warnf("=== LOGOUT EVENT RECEIVED ===")
|
|
348
|
+
s.logger.Warnf("OnConnect: %v", v.OnConnect)
|
|
349
|
+
s.logger.Warnf("Reason: %s", v.Reason.String())
|
|
350
|
+
s.logger.Warnf("Full logout event: %+v", v)
|
|
351
|
+
s.logger.Warnf("Device Store ID: %v", s.client.Store.ID)
|
|
352
|
+
s.logger.Warnf("Is Connected: %v", s.client.IsConnected())
|
|
353
|
+
s.logger.Warnf("=== END LOGOUT EVENT ===")
|
|
354
|
+
s.safeEventSend(Event{
|
|
355
|
+
Type: "logged_out",
|
|
356
|
+
Data: map[string]interface{}{
|
|
357
|
+
"on_connect": v.OnConnect,
|
|
358
|
+
"reason": v.Reason.String(),
|
|
359
|
+
"full_event": fmt.Sprintf("%+v", v),
|
|
360
|
+
},
|
|
361
|
+
Time: time.Now(),
|
|
362
|
+
})
|
|
363
|
+
case *events.TemporaryBan:
|
|
364
|
+
s.logger.Errorf("Temporary ban received: %+v", v)
|
|
365
|
+
s.safeEventSend(Event{
|
|
366
|
+
Type: "temporary_ban",
|
|
367
|
+
Data: map[string]interface{}{
|
|
368
|
+
"code": v.Code.String(),
|
|
369
|
+
"reason": fmt.Sprintf("%+v", v),
|
|
370
|
+
},
|
|
371
|
+
Time: time.Now(),
|
|
372
|
+
})
|
|
373
|
+
case *events.HistorySync:
|
|
374
|
+
s.handleHistorySync(v)
|
|
375
|
+
case *events.Message:
|
|
376
|
+
s.handleIncomingMessage(v)
|
|
377
|
+
default:
|
|
378
|
+
s.logger.Debugf("Unhandled event type: %T, data: %+v", evt, evt)
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
// handleIncomingMessage processes incoming messages and extracts all message types
|
|
383
|
+
func (s *Service) handleIncomingMessage(v *events.Message) {
|
|
384
|
+
// Extract basic info
|
|
385
|
+
sender := v.Info.Sender.String()
|
|
386
|
+
chatID := v.Info.Chat.String()
|
|
387
|
+
timestamp := v.Info.Timestamp
|
|
388
|
+
messageID := v.Info.ID
|
|
389
|
+
isGroup := v.Info.IsGroup
|
|
390
|
+
|
|
391
|
+
// Resolve LID to phone number (LIDs can appear in both group and individual chats)
|
|
392
|
+
senderPhone := v.Info.Sender.User // Default to JID user part
|
|
393
|
+
if v.Info.Sender.Server == "lid" && s.client.Store.LIDs != nil {
|
|
394
|
+
ctx := context.Background()
|
|
395
|
+
pn, err := s.client.Store.LIDs.GetPNForLID(ctx, v.Info.Sender)
|
|
396
|
+
if err == nil && pn.User != "" {
|
|
397
|
+
senderPhone = pn.User
|
|
398
|
+
s.logger.Debugf("Resolved LID %s to phone %s", sender, senderPhone)
|
|
399
|
+
} else if err != nil {
|
|
400
|
+
s.logger.Debugf("Could not resolve LID %s: %v", sender, err)
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
if isGroup {
|
|
405
|
+
s.logger.Debugf("Processing group message from %s (phone: %s) in %s (ID: %s)", sender, senderPhone, chatID, messageID)
|
|
406
|
+
} else {
|
|
407
|
+
s.logger.Debugf("Processing individual message from %s (phone: %s) (ID: %s)", sender, senderPhone, messageID)
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
// Store message for later media download (keep last 100 messages to avoid memory issues)
|
|
411
|
+
s.mu.Lock()
|
|
412
|
+
// Check if message already exists (avoid duplicates)
|
|
413
|
+
if _, exists := s.messages[messageID]; !exists {
|
|
414
|
+
// Remove oldest messages if at capacity (FIFO cleanup)
|
|
415
|
+
maxMessages := 100
|
|
416
|
+
for len(s.messageOrder) >= maxMessages {
|
|
417
|
+
oldestID := s.messageOrder[0]
|
|
418
|
+
s.messageOrder = s.messageOrder[1:]
|
|
419
|
+
delete(s.messages, oldestID)
|
|
420
|
+
s.logger.Debugf("Removed oldest message %s from cache (capacity: %d)", oldestID, maxMessages)
|
|
421
|
+
}
|
|
422
|
+
// Add new message
|
|
423
|
+
s.messages[messageID] = v
|
|
424
|
+
s.messageOrder = append(s.messageOrder, messageID)
|
|
425
|
+
s.logger.Debugf("Cached message %s for media download (total: %d)", messageID, len(s.messages))
|
|
426
|
+
}
|
|
427
|
+
s.mu.Unlock()
|
|
428
|
+
|
|
429
|
+
// Prepare event data with common fields
|
|
430
|
+
eventData := map[string]interface{}{
|
|
431
|
+
"message_id": messageID,
|
|
432
|
+
"sender": sender,
|
|
433
|
+
"sender_phone": senderPhone,
|
|
434
|
+
"chat_id": chatID,
|
|
435
|
+
"timestamp": timestamp,
|
|
436
|
+
"is_from_me": v.Info.IsFromMe,
|
|
437
|
+
"is_group": isGroup,
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
// Add group info for group messages
|
|
441
|
+
if isGroup {
|
|
442
|
+
groupInfo := map[string]interface{}{
|
|
443
|
+
"group_jid": chatID,
|
|
444
|
+
"sender_jid": sender,
|
|
445
|
+
"sender_phone": senderPhone,
|
|
446
|
+
}
|
|
447
|
+
// Try to get group name from push name or leave empty
|
|
448
|
+
if v.Info.PushName != "" {
|
|
449
|
+
groupInfo["sender_name"] = v.Info.PushName
|
|
450
|
+
}
|
|
451
|
+
eventData["group_info"] = groupInfo
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
// Determine message type and extract content
|
|
455
|
+
msg := v.Message
|
|
456
|
+
messageType := "unknown"
|
|
457
|
+
var messageContent interface{}
|
|
458
|
+
|
|
459
|
+
// Helper function to extract forwarded info from ContextInfo
|
|
460
|
+
extractForwardedInfo := func(ctx *waE2E.ContextInfo) {
|
|
461
|
+
if ctx == nil {
|
|
462
|
+
return
|
|
463
|
+
}
|
|
464
|
+
if ctx.GetIsForwarded() {
|
|
465
|
+
eventData["is_forwarded"] = true
|
|
466
|
+
eventData["forwarding_score"] = ctx.GetForwardingScore()
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
switch {
|
|
471
|
+
case msg.Conversation != nil && *msg.Conversation != "":
|
|
472
|
+
// Plain text message (no ContextInfo, cannot be forwarded)
|
|
473
|
+
messageType = "text"
|
|
474
|
+
messageContent = *msg.Conversation
|
|
475
|
+
eventData["text"] = *msg.Conversation
|
|
476
|
+
|
|
477
|
+
case msg.ExtendedTextMessage != nil:
|
|
478
|
+
// Extended text message (with link preview, quoted message, etc.)
|
|
479
|
+
messageType = "text"
|
|
480
|
+
if msg.ExtendedTextMessage.Text != nil {
|
|
481
|
+
messageContent = *msg.ExtendedTextMessage.Text
|
|
482
|
+
eventData["text"] = *msg.ExtendedTextMessage.Text
|
|
483
|
+
}
|
|
484
|
+
// Check forwarded status
|
|
485
|
+
extractForwardedInfo(msg.ExtendedTextMessage.ContextInfo)
|
|
486
|
+
// Check if it's a reply/quoted message
|
|
487
|
+
if msg.ExtendedTextMessage.ContextInfo != nil && msg.ExtendedTextMessage.ContextInfo.QuotedMessage != nil {
|
|
488
|
+
eventData["is_reply"] = true
|
|
489
|
+
if msg.ExtendedTextMessage.ContextInfo.StanzaID != nil {
|
|
490
|
+
eventData["quoted_message_id"] = *msg.ExtendedTextMessage.ContextInfo.StanzaID
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
case msg.ImageMessage != nil:
|
|
495
|
+
// Image message
|
|
496
|
+
messageType = "image"
|
|
497
|
+
imageData := map[string]interface{}{
|
|
498
|
+
"mime_type": msg.ImageMessage.GetMimetype(),
|
|
499
|
+
"file_sha256": fmt.Sprintf("%x", msg.ImageMessage.GetFileSHA256()),
|
|
500
|
+
"file_length": msg.ImageMessage.GetFileLength(),
|
|
501
|
+
}
|
|
502
|
+
if msg.ImageMessage.Caption != nil {
|
|
503
|
+
imageData["caption"] = *msg.ImageMessage.Caption
|
|
504
|
+
}
|
|
505
|
+
if msg.ImageMessage.URL != nil {
|
|
506
|
+
imageData["url"] = *msg.ImageMessage.URL
|
|
507
|
+
}
|
|
508
|
+
messageContent = imageData
|
|
509
|
+
eventData["image"] = imageData
|
|
510
|
+
// Check forwarded status
|
|
511
|
+
extractForwardedInfo(msg.ImageMessage.ContextInfo)
|
|
512
|
+
|
|
513
|
+
case msg.VideoMessage != nil:
|
|
514
|
+
// Video message
|
|
515
|
+
messageType = "video"
|
|
516
|
+
videoData := map[string]interface{}{
|
|
517
|
+
"mime_type": msg.VideoMessage.GetMimetype(),
|
|
518
|
+
"file_sha256": fmt.Sprintf("%x", msg.VideoMessage.GetFileSHA256()),
|
|
519
|
+
"file_length": msg.VideoMessage.GetFileLength(),
|
|
520
|
+
"seconds": msg.VideoMessage.GetSeconds(),
|
|
521
|
+
}
|
|
522
|
+
if msg.VideoMessage.Caption != nil {
|
|
523
|
+
videoData["caption"] = *msg.VideoMessage.Caption
|
|
524
|
+
}
|
|
525
|
+
if msg.VideoMessage.URL != nil {
|
|
526
|
+
videoData["url"] = *msg.VideoMessage.URL
|
|
527
|
+
}
|
|
528
|
+
messageContent = videoData
|
|
529
|
+
eventData["video"] = videoData
|
|
530
|
+
// Check forwarded status
|
|
531
|
+
extractForwardedInfo(msg.VideoMessage.ContextInfo)
|
|
532
|
+
|
|
533
|
+
case msg.AudioMessage != nil:
|
|
534
|
+
// Audio/Voice message
|
|
535
|
+
messageType = "audio"
|
|
536
|
+
audioData := map[string]interface{}{
|
|
537
|
+
"mime_type": msg.AudioMessage.GetMimetype(),
|
|
538
|
+
"file_sha256": fmt.Sprintf("%x", msg.AudioMessage.GetFileSHA256()),
|
|
539
|
+
"file_length": msg.AudioMessage.GetFileLength(),
|
|
540
|
+
"seconds": msg.AudioMessage.GetSeconds(),
|
|
541
|
+
"ptt": msg.AudioMessage.GetPTT(), // Push-to-talk (voice message)
|
|
542
|
+
}
|
|
543
|
+
if msg.AudioMessage.URL != nil {
|
|
544
|
+
audioData["url"] = *msg.AudioMessage.URL
|
|
545
|
+
}
|
|
546
|
+
messageContent = audioData
|
|
547
|
+
eventData["audio"] = audioData
|
|
548
|
+
// Check forwarded status
|
|
549
|
+
extractForwardedInfo(msg.AudioMessage.ContextInfo)
|
|
550
|
+
|
|
551
|
+
case msg.DocumentMessage != nil:
|
|
552
|
+
// Document message
|
|
553
|
+
messageType = "document"
|
|
554
|
+
docData := map[string]interface{}{
|
|
555
|
+
"mime_type": msg.DocumentMessage.GetMimetype(),
|
|
556
|
+
"file_sha256": fmt.Sprintf("%x", msg.DocumentMessage.GetFileSHA256()),
|
|
557
|
+
"file_length": msg.DocumentMessage.GetFileLength(),
|
|
558
|
+
}
|
|
559
|
+
if msg.DocumentMessage.FileName != nil {
|
|
560
|
+
docData["file_name"] = *msg.DocumentMessage.FileName
|
|
561
|
+
}
|
|
562
|
+
if msg.DocumentMessage.Title != nil {
|
|
563
|
+
docData["title"] = *msg.DocumentMessage.Title
|
|
564
|
+
}
|
|
565
|
+
if msg.DocumentMessage.Caption != nil {
|
|
566
|
+
docData["caption"] = *msg.DocumentMessage.Caption
|
|
567
|
+
}
|
|
568
|
+
if msg.DocumentMessage.URL != nil {
|
|
569
|
+
docData["url"] = *msg.DocumentMessage.URL
|
|
570
|
+
}
|
|
571
|
+
messageContent = docData
|
|
572
|
+
eventData["document"] = docData
|
|
573
|
+
// Check forwarded status
|
|
574
|
+
extractForwardedInfo(msg.DocumentMessage.ContextInfo)
|
|
575
|
+
|
|
576
|
+
case msg.StickerMessage != nil:
|
|
577
|
+
// Sticker message
|
|
578
|
+
messageType = "sticker"
|
|
579
|
+
stickerData := map[string]interface{}{
|
|
580
|
+
"mime_type": msg.StickerMessage.GetMimetype(),
|
|
581
|
+
"file_sha256": fmt.Sprintf("%x", msg.StickerMessage.GetFileSHA256()),
|
|
582
|
+
"file_length": msg.StickerMessage.GetFileLength(),
|
|
583
|
+
"is_animated": msg.StickerMessage.GetIsAnimated(),
|
|
584
|
+
}
|
|
585
|
+
if msg.StickerMessage.URL != nil {
|
|
586
|
+
stickerData["url"] = *msg.StickerMessage.URL
|
|
587
|
+
}
|
|
588
|
+
messageContent = stickerData
|
|
589
|
+
eventData["sticker"] = stickerData
|
|
590
|
+
// Check forwarded status
|
|
591
|
+
extractForwardedInfo(msg.StickerMessage.ContextInfo)
|
|
592
|
+
|
|
593
|
+
case msg.LocationMessage != nil:
|
|
594
|
+
// Location message
|
|
595
|
+
messageType = "location"
|
|
596
|
+
locationData := map[string]interface{}{
|
|
597
|
+
"latitude": msg.LocationMessage.GetDegreesLatitude(),
|
|
598
|
+
"longitude": msg.LocationMessage.GetDegreesLongitude(),
|
|
599
|
+
}
|
|
600
|
+
if msg.LocationMessage.Name != nil {
|
|
601
|
+
locationData["name"] = *msg.LocationMessage.Name
|
|
602
|
+
}
|
|
603
|
+
if msg.LocationMessage.Address != nil {
|
|
604
|
+
locationData["address"] = *msg.LocationMessage.Address
|
|
605
|
+
}
|
|
606
|
+
messageContent = locationData
|
|
607
|
+
eventData["location"] = locationData
|
|
608
|
+
|
|
609
|
+
case msg.ContactMessage != nil:
|
|
610
|
+
// Contact card message
|
|
611
|
+
messageType = "contact"
|
|
612
|
+
contactData := map[string]interface{}{
|
|
613
|
+
"display_name": msg.ContactMessage.GetDisplayName(),
|
|
614
|
+
"vcard": msg.ContactMessage.GetVcard(),
|
|
615
|
+
}
|
|
616
|
+
messageContent = contactData
|
|
617
|
+
eventData["contact"] = contactData
|
|
618
|
+
|
|
619
|
+
case msg.ContactsArrayMessage != nil:
|
|
620
|
+
// Multiple contacts
|
|
621
|
+
messageType = "contacts"
|
|
622
|
+
contacts := []map[string]interface{}{}
|
|
623
|
+
for _, contact := range msg.ContactsArrayMessage.Contacts {
|
|
624
|
+
contacts = append(contacts, map[string]interface{}{
|
|
625
|
+
"display_name": contact.GetDisplayName(),
|
|
626
|
+
"vcard": contact.GetVcard(),
|
|
627
|
+
})
|
|
628
|
+
}
|
|
629
|
+
messageContent = contacts
|
|
630
|
+
eventData["contacts"] = contacts
|
|
631
|
+
|
|
632
|
+
case msg.ReactionMessage != nil:
|
|
633
|
+
// Reaction to a message - skip broadcasting these
|
|
634
|
+
s.logger.Debugf("Skipping reaction message from %s to message %s", sender, msg.ReactionMessage.GetKey().GetID())
|
|
635
|
+
return
|
|
636
|
+
|
|
637
|
+
case msg.ProtocolMessage != nil:
|
|
638
|
+
// Protocol messages (edits, deletes, ephemeral settings) - skip these
|
|
639
|
+
s.logger.Debugf("Skipping protocol message from %s (type: %v)", sender, msg.ProtocolMessage.GetType())
|
|
640
|
+
return
|
|
641
|
+
|
|
642
|
+
default:
|
|
643
|
+
// Unknown message type - silently skip (don't log or broadcast)
|
|
644
|
+
return
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
eventData["message_type"] = messageType
|
|
648
|
+
eventData["content"] = messageContent
|
|
649
|
+
|
|
650
|
+
// Log the message (debug level to reduce noise)
|
|
651
|
+
s.logger.Debugf("Received %s message from %s", messageType, sender)
|
|
652
|
+
|
|
653
|
+
// Persist ALL messages to history store for future retrieval
|
|
654
|
+
if s.historyStore != nil {
|
|
655
|
+
textContent := ""
|
|
656
|
+
if t, ok := eventData["text"].(string); ok {
|
|
657
|
+
textContent = t
|
|
658
|
+
}
|
|
659
|
+
record := MessageRecord{
|
|
660
|
+
MessageID: messageID,
|
|
661
|
+
ChatID: chatID,
|
|
662
|
+
Sender: sender,
|
|
663
|
+
SenderPhone: senderPhone,
|
|
664
|
+
MessageType: messageType,
|
|
665
|
+
Text: textContent,
|
|
666
|
+
Timestamp: timestamp,
|
|
667
|
+
IsGroup: isGroup,
|
|
668
|
+
IsFromMe: v.Info.IsFromMe,
|
|
669
|
+
}
|
|
670
|
+
go func() {
|
|
671
|
+
if err := s.historyStore.StoreMessage(record); err != nil {
|
|
672
|
+
s.logger.Debugf("Failed to store message: %v", err)
|
|
673
|
+
}
|
|
674
|
+
}()
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
// Broadcast event
|
|
678
|
+
s.safeEventSend(Event{
|
|
679
|
+
Type: "message_received",
|
|
680
|
+
Data: eventData,
|
|
681
|
+
Time: time.Now(),
|
|
682
|
+
})
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
// handleHistorySync processes history sync events received on first login
|
|
686
|
+
// This contains all past conversations and messages from WhatsApp
|
|
687
|
+
func (s *Service) handleHistorySync(evt *events.HistorySync) {
|
|
688
|
+
if s.historyStore == nil {
|
|
689
|
+
s.logger.Warn("History sync received but history store not initialized")
|
|
690
|
+
return
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
historyData := evt.Data
|
|
694
|
+
if historyData == nil {
|
|
695
|
+
return
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
conversations := historyData.GetConversations()
|
|
699
|
+
s.logger.Infof("Processing history sync: %d conversations", len(conversations))
|
|
700
|
+
|
|
701
|
+
totalMessages := 0
|
|
702
|
+
for _, conv := range conversations {
|
|
703
|
+
chatJID, err := types.ParseJID(conv.GetID())
|
|
704
|
+
if err != nil {
|
|
705
|
+
s.logger.Warnf("Invalid chat JID in history: %s", conv.GetID())
|
|
706
|
+
continue
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
isGroup := chatJID.Server == "g.us"
|
|
710
|
+
chatID := chatJID.String()
|
|
711
|
+
|
|
712
|
+
for _, historyMsg := range conv.GetMessages() {
|
|
713
|
+
webMsg := historyMsg.GetMessage()
|
|
714
|
+
if webMsg == nil {
|
|
715
|
+
continue
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
// Parse the web message to get standard message format
|
|
719
|
+
parsedMsg, err := s.client.ParseWebMessage(chatJID, webMsg)
|
|
720
|
+
if err != nil {
|
|
721
|
+
continue
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
// Extract message info
|
|
725
|
+
msgInfo := parsedMsg.Info
|
|
726
|
+
messageID := msgInfo.ID
|
|
727
|
+
sender := msgInfo.Sender.String()
|
|
728
|
+
senderPhone := msgInfo.Sender.User
|
|
729
|
+
timestamp := msgInfo.Timestamp
|
|
730
|
+
|
|
731
|
+
// Determine message type and extract text content
|
|
732
|
+
messageType := "unknown"
|
|
733
|
+
text := ""
|
|
734
|
+
|
|
735
|
+
msg := parsedMsg.Message
|
|
736
|
+
if msg == nil {
|
|
737
|
+
// Skip messages with nil content (protocol messages, etc.)
|
|
738
|
+
continue
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
switch {
|
|
742
|
+
case msg.Conversation != nil && *msg.Conversation != "":
|
|
743
|
+
messageType = "text"
|
|
744
|
+
text = *msg.Conversation
|
|
745
|
+
case msg.ExtendedTextMessage != nil && msg.ExtendedTextMessage.Text != nil:
|
|
746
|
+
messageType = "text"
|
|
747
|
+
text = *msg.ExtendedTextMessage.Text
|
|
748
|
+
case msg.ImageMessage != nil:
|
|
749
|
+
messageType = "image"
|
|
750
|
+
if msg.ImageMessage.Caption != nil {
|
|
751
|
+
text = *msg.ImageMessage.Caption
|
|
752
|
+
}
|
|
753
|
+
case msg.VideoMessage != nil:
|
|
754
|
+
messageType = "video"
|
|
755
|
+
if msg.VideoMessage.Caption != nil {
|
|
756
|
+
text = *msg.VideoMessage.Caption
|
|
757
|
+
}
|
|
758
|
+
case msg.AudioMessage != nil:
|
|
759
|
+
messageType = "audio"
|
|
760
|
+
case msg.DocumentMessage != nil:
|
|
761
|
+
messageType = "document"
|
|
762
|
+
if msg.DocumentMessage.Caption != nil {
|
|
763
|
+
text = *msg.DocumentMessage.Caption
|
|
764
|
+
}
|
|
765
|
+
case msg.StickerMessage != nil:
|
|
766
|
+
messageType = "sticker"
|
|
767
|
+
case msg.LocationMessage != nil:
|
|
768
|
+
messageType = "location"
|
|
769
|
+
case msg.ContactMessage != nil:
|
|
770
|
+
messageType = "contact"
|
|
771
|
+
default:
|
|
772
|
+
// Skip unknown/protocol messages
|
|
773
|
+
continue
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
// Store ALL message types
|
|
777
|
+
record := MessageRecord{
|
|
778
|
+
MessageID: messageID,
|
|
779
|
+
ChatID: chatID,
|
|
780
|
+
Sender: sender,
|
|
781
|
+
SenderPhone: senderPhone,
|
|
782
|
+
MessageType: messageType,
|
|
783
|
+
Text: text,
|
|
784
|
+
Timestamp: timestamp,
|
|
785
|
+
IsGroup: isGroup,
|
|
786
|
+
IsFromMe: msgInfo.IsFromMe,
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
if err := s.historyStore.StoreMessage(record); err != nil {
|
|
790
|
+
s.logger.Debugf("Failed to store history message: %v", err)
|
|
791
|
+
} else {
|
|
792
|
+
totalMessages++
|
|
793
|
+
}
|
|
794
|
+
}
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
s.logger.Infof("History sync complete: stored %d messages from %d conversations",
|
|
798
|
+
totalMessages, len(conversations))
|
|
799
|
+
|
|
800
|
+
// Broadcast event for frontend notification
|
|
801
|
+
s.safeEventSend(Event{
|
|
802
|
+
Type: "history_sync_complete",
|
|
803
|
+
Data: map[string]interface{}{
|
|
804
|
+
"conversations": len(conversations),
|
|
805
|
+
"messages": totalMessages,
|
|
806
|
+
},
|
|
807
|
+
Time: time.Now(),
|
|
808
|
+
})
|
|
809
|
+
}
|
|
810
|
+
|
|
811
|
+
// GetChatHistory retrieves stored messages for a chat
|
|
812
|
+
func (s *Service) GetChatHistory(chatID string, limit, offset int, senderPhone string, textOnly bool) (*ChatHistoryResult, error) {
|
|
813
|
+
if s.historyStore == nil {
|
|
814
|
+
return nil, fmt.Errorf("history store not initialized")
|
|
815
|
+
}
|
|
816
|
+
return s.historyStore.GetChatHistory(chatID, limit, offset, senderPhone, textOnly)
|
|
817
|
+
}
|
|
818
|
+
|
|
819
|
+
func (s *Service) GetStatus() map[string]interface{} {
|
|
820
|
+
status := map[string]interface{}{
|
|
821
|
+
"connected": s.client.IsConnected(),
|
|
822
|
+
"has_session": s.client.Store.ID != nil,
|
|
823
|
+
"running": s.running,
|
|
824
|
+
"pairing": s.pairing,
|
|
825
|
+
"timestamp": time.Now(),
|
|
826
|
+
}
|
|
827
|
+
|
|
828
|
+
// Add device information if available
|
|
829
|
+
if s.client.Store.ID != nil {
|
|
830
|
+
status["device_id"] = s.client.Store.ID.String()
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
return status
|
|
834
|
+
}
|
|
835
|
+
|
|
836
|
+
func (s *Service) GetDiagnostics() map[string]interface{} {
|
|
837
|
+
diagnostics := map[string]interface{}{
|
|
838
|
+
"client_connected": s.client.IsConnected(),
|
|
839
|
+
"client_logged_in": s.client.IsLoggedIn(),
|
|
840
|
+
"store_has_id": s.client.Store.ID != nil,
|
|
841
|
+
"service_running": s.running,
|
|
842
|
+
"service_pairing": s.pairing,
|
|
843
|
+
"service_shutdown": s.shutdown,
|
|
844
|
+
"events_channel_open": !s.shutdown,
|
|
845
|
+
"timestamp": time.Now(),
|
|
846
|
+
}
|
|
847
|
+
|
|
848
|
+
// Add store information if available
|
|
849
|
+
if s.client.Store.ID != nil {
|
|
850
|
+
diagnostics["device_store"] = map[string]interface{}{
|
|
851
|
+
"device_id": s.client.Store.ID.String(),
|
|
852
|
+
"user": s.client.Store.ID.User,
|
|
853
|
+
"device": s.client.Store.ID.Device,
|
|
854
|
+
"server": s.client.Store.ID.Server,
|
|
855
|
+
}
|
|
856
|
+
}
|
|
857
|
+
|
|
858
|
+
return diagnostics
|
|
859
|
+
}
|
|
860
|
+
|
|
861
|
+
func (s *Service) GetEventChannel() <-chan Event {
|
|
862
|
+
return s.events
|
|
863
|
+
}
|
|
864
|
+
|
|
865
|
+
func (s *Service) IsConnected() bool {
|
|
866
|
+
return s.client.IsConnected()
|
|
867
|
+
}
|
|
868
|
+
|
|
869
|
+
// DownloadMedia downloads media from a message by ID
|
|
870
|
+
func (s *Service) DownloadMedia(messageID string) ([]byte, string, error) {
|
|
871
|
+
s.mu.Lock()
|
|
872
|
+
msg, exists := s.messages[messageID]
|
|
873
|
+
cachedCount := len(s.messages)
|
|
874
|
+
s.mu.Unlock()
|
|
875
|
+
|
|
876
|
+
if !exists {
|
|
877
|
+
s.logger.Warnf("Media download failed: message %s not found in cache (cached: %d messages)", messageID, cachedCount)
|
|
878
|
+
return nil, "", fmt.Errorf("message not found or expired (message_id: %s, cached: %d)", messageID, cachedCount)
|
|
879
|
+
}
|
|
880
|
+
|
|
881
|
+
// Download media based on message type
|
|
882
|
+
var data []byte
|
|
883
|
+
var err error
|
|
884
|
+
var mimeType string
|
|
885
|
+
var mediaType string
|
|
886
|
+
|
|
887
|
+
ctx := context.Background()
|
|
888
|
+
|
|
889
|
+
switch {
|
|
890
|
+
case msg.Message.ImageMessage != nil:
|
|
891
|
+
mediaType = "image"
|
|
892
|
+
s.logger.Infof("Downloading image from message %s", messageID)
|
|
893
|
+
data, err = s.client.Download(ctx, msg.Message.ImageMessage)
|
|
894
|
+
mimeType = msg.Message.ImageMessage.GetMimetype()
|
|
895
|
+
case msg.Message.VideoMessage != nil:
|
|
896
|
+
mediaType = "video"
|
|
897
|
+
s.logger.Infof("Downloading video from message %s", messageID)
|
|
898
|
+
data, err = s.client.Download(ctx, msg.Message.VideoMessage)
|
|
899
|
+
mimeType = msg.Message.VideoMessage.GetMimetype()
|
|
900
|
+
case msg.Message.AudioMessage != nil:
|
|
901
|
+
mediaType = "audio"
|
|
902
|
+
s.logger.Infof("Downloading audio from message %s", messageID)
|
|
903
|
+
data, err = s.client.Download(ctx, msg.Message.AudioMessage)
|
|
904
|
+
mimeType = msg.Message.AudioMessage.GetMimetype()
|
|
905
|
+
case msg.Message.DocumentMessage != nil:
|
|
906
|
+
mediaType = "document"
|
|
907
|
+
s.logger.Infof("Downloading document from message %s", messageID)
|
|
908
|
+
data, err = s.client.Download(ctx, msg.Message.DocumentMessage)
|
|
909
|
+
mimeType = msg.Message.DocumentMessage.GetMimetype()
|
|
910
|
+
case msg.Message.StickerMessage != nil:
|
|
911
|
+
mediaType = "sticker"
|
|
912
|
+
s.logger.Infof("Downloading sticker from message %s", messageID)
|
|
913
|
+
data, err = s.client.Download(ctx, msg.Message.StickerMessage)
|
|
914
|
+
mimeType = msg.Message.StickerMessage.GetMimetype()
|
|
915
|
+
default:
|
|
916
|
+
s.logger.Warnf("Media download failed: message %s does not contain downloadable media", messageID)
|
|
917
|
+
return nil, "", fmt.Errorf("message does not contain downloadable media")
|
|
918
|
+
}
|
|
919
|
+
|
|
920
|
+
if err != nil {
|
|
921
|
+
s.logger.Errorf("Failed to download %s from message %s: %v", mediaType, messageID, err)
|
|
922
|
+
return nil, "", fmt.Errorf("failed to download %s: %w", mediaType, err)
|
|
923
|
+
}
|
|
924
|
+
|
|
925
|
+
s.logger.Infof("Successfully downloaded %s from message %s (%d bytes, %s)", mediaType, messageID, len(data), mimeType)
|
|
926
|
+
return data, mimeType, nil
|
|
927
|
+
}
|
|
928
|
+
|
|
929
|
+
// resolveParticipantPhone gets the real phone number for a participant
|
|
930
|
+
// Uses LID store to resolve if PhoneNumber is empty and JID is a LID
|
|
931
|
+
func (s *Service) resolveParticipantPhone(ctx context.Context, p types.GroupParticipant) string {
|
|
932
|
+
// First try PhoneNumber field
|
|
933
|
+
if p.PhoneNumber.User != "" {
|
|
934
|
+
return p.PhoneNumber.User
|
|
935
|
+
}
|
|
936
|
+
|
|
937
|
+
// Check if JID is a LID (ends with @lid server)
|
|
938
|
+
if p.JID.Server == "lid" && s.client.Store.LIDs != nil {
|
|
939
|
+
// Try to resolve LID to phone number
|
|
940
|
+
pn, err := s.client.Store.LIDs.GetPNForLID(ctx, p.JID)
|
|
941
|
+
if err == nil && pn.User != "" {
|
|
942
|
+
return pn.User
|
|
943
|
+
}
|
|
944
|
+
}
|
|
945
|
+
|
|
946
|
+
// Fall back to JID.User
|
|
947
|
+
return p.JID.User
|
|
948
|
+
}
|
|
949
|
+
|
|
950
|
+
// GetContactName looks up a contact name by phone number
|
|
951
|
+
// Prefers user's saved name, falls back to push name
|
|
952
|
+
func (s *Service) GetContactName(phone string) string {
|
|
953
|
+
if s.client == nil || s.client.Store == nil || s.client.Store.Contacts == nil {
|
|
954
|
+
return ""
|
|
955
|
+
}
|
|
956
|
+
|
|
957
|
+
jid := types.NewJID(phone, types.DefaultUserServer)
|
|
958
|
+
contact, err := s.client.Store.Contacts.GetContact(context.Background(), jid)
|
|
959
|
+
if err != nil {
|
|
960
|
+
return ""
|
|
961
|
+
}
|
|
962
|
+
|
|
963
|
+
// Prefer user's saved name, fall back to push name
|
|
964
|
+
if contact.FullName != "" {
|
|
965
|
+
return contact.FullName
|
|
966
|
+
}
|
|
967
|
+
return contact.PushName
|
|
968
|
+
}
|
|
969
|
+
|
|
970
|
+
// GetContacts returns all stored contacts with names
|
|
971
|
+
func (s *Service) GetContacts(query string) ([]ContactInfo, error) {
|
|
972
|
+
if !s.client.IsConnected() {
|
|
973
|
+
return nil, fmt.Errorf("WhatsApp not connected")
|
|
974
|
+
}
|
|
975
|
+
|
|
976
|
+
ctx := context.Background()
|
|
977
|
+
contacts, err := s.client.Store.Contacts.GetAllContacts(ctx)
|
|
978
|
+
if err != nil {
|
|
979
|
+
return nil, fmt.Errorf("failed to get contacts: %w", err)
|
|
980
|
+
}
|
|
981
|
+
|
|
982
|
+
result := make([]ContactInfo, 0)
|
|
983
|
+
queryLower := strings.ToLower(query)
|
|
984
|
+
|
|
985
|
+
for jid, info := range contacts {
|
|
986
|
+
// Skip non-user JIDs (groups, etc.)
|
|
987
|
+
if jid.Server != types.DefaultUserServer {
|
|
988
|
+
continue
|
|
989
|
+
}
|
|
990
|
+
|
|
991
|
+
// Filter by query if provided
|
|
992
|
+
if query != "" {
|
|
993
|
+
nameMatch := strings.Contains(strings.ToLower(info.FullName), queryLower) ||
|
|
994
|
+
strings.Contains(strings.ToLower(info.PushName), queryLower) ||
|
|
995
|
+
strings.Contains(jid.User, query)
|
|
996
|
+
if !nameMatch {
|
|
997
|
+
continue
|
|
998
|
+
}
|
|
999
|
+
}
|
|
1000
|
+
|
|
1001
|
+
result = append(result, ContactInfo{
|
|
1002
|
+
JID: jid.String(),
|
|
1003
|
+
Phone: jid.User,
|
|
1004
|
+
Name: info.FullName,
|
|
1005
|
+
PushName: info.PushName,
|
|
1006
|
+
IsContact: true,
|
|
1007
|
+
})
|
|
1008
|
+
}
|
|
1009
|
+
|
|
1010
|
+
s.logger.Infof("Retrieved %d contacts (query: %q)", len(result), query)
|
|
1011
|
+
return result, nil
|
|
1012
|
+
}
|
|
1013
|
+
|
|
1014
|
+
// GetContactInfo returns full info for a single contact (for send/reply use case)
|
|
1015
|
+
func (s *Service) GetContactInfo(phone string) (*ContactInfo, error) {
|
|
1016
|
+
if !s.client.IsConnected() {
|
|
1017
|
+
return nil, fmt.Errorf("WhatsApp not connected")
|
|
1018
|
+
}
|
|
1019
|
+
|
|
1020
|
+
// Clean phone number - remove + prefix if present
|
|
1021
|
+
phone = strings.TrimPrefix(phone, "+")
|
|
1022
|
+
|
|
1023
|
+
jid := types.NewJID(phone, types.DefaultUserServer)
|
|
1024
|
+
ctx := context.Background()
|
|
1025
|
+
|
|
1026
|
+
result := &ContactInfo{
|
|
1027
|
+
JID: jid.String(),
|
|
1028
|
+
Phone: jid.User,
|
|
1029
|
+
}
|
|
1030
|
+
|
|
1031
|
+
// Get contact details from store (saved names)
|
|
1032
|
+
if s.client.Store != nil && s.client.Store.Contacts != nil {
|
|
1033
|
+
contact, err := s.client.Store.Contacts.GetContact(ctx, jid)
|
|
1034
|
+
if err == nil {
|
|
1035
|
+
result.Name = contact.FullName
|
|
1036
|
+
result.PushName = contact.PushName
|
|
1037
|
+
result.BusinessName = contact.BusinessName
|
|
1038
|
+
result.IsContact = true
|
|
1039
|
+
}
|
|
1040
|
+
}
|
|
1041
|
+
|
|
1042
|
+
// Check WhatsApp registration + business info
|
|
1043
|
+
resp, err := s.client.IsOnWhatsApp(ctx, []string{"+" + jid.User})
|
|
1044
|
+
if err == nil && len(resp) > 0 {
|
|
1045
|
+
if resp[0].VerifiedName != nil {
|
|
1046
|
+
result.IsBusiness = true
|
|
1047
|
+
result.BusinessName = resp[0].VerifiedName.Details.GetVerifiedName()
|
|
1048
|
+
}
|
|
1049
|
+
}
|
|
1050
|
+
|
|
1051
|
+
// Get profile photo URL
|
|
1052
|
+
picInfo, err := s.client.GetProfilePictureInfo(ctx, jid, &whatsmeow.GetProfilePictureParams{Preview: false})
|
|
1053
|
+
if err == nil && picInfo != nil && picInfo.URL != "" {
|
|
1054
|
+
result.ProfilePic = picInfo.URL
|
|
1055
|
+
}
|
|
1056
|
+
|
|
1057
|
+
s.logger.Infof("Retrieved contact info for %s: name=%q", phone, result.Name)
|
|
1058
|
+
return result, nil
|
|
1059
|
+
}
|
|
1060
|
+
|
|
1061
|
+
// generateGroupsJSON fetches all groups and saves to data/groups.json
|
|
1062
|
+
// Called automatically on WhatsApp connection for fast offline access
|
|
1063
|
+
func (s *Service) generateGroupsJSON() error {
|
|
1064
|
+
if !s.client.IsConnected() {
|
|
1065
|
+
return fmt.Errorf("not connected")
|
|
1066
|
+
}
|
|
1067
|
+
|
|
1068
|
+
ctx := context.Background()
|
|
1069
|
+
groups, err := s.client.GetJoinedGroups(ctx)
|
|
1070
|
+
if err != nil {
|
|
1071
|
+
return fmt.Errorf("failed to get groups: %w", err)
|
|
1072
|
+
}
|
|
1073
|
+
|
|
1074
|
+
// Build indexed structure for fast lookups
|
|
1075
|
+
groupsMap := make(map[string]interface{})
|
|
1076
|
+
phoneToGroups := make(map[string][]string)
|
|
1077
|
+
|
|
1078
|
+
for _, g := range groups {
|
|
1079
|
+
participantsMap := make(map[string]interface{})
|
|
1080
|
+
|
|
1081
|
+
for _, p := range g.Participants {
|
|
1082
|
+
phone := s.resolveParticipantPhone(ctx, p)
|
|
1083
|
+
participantsMap[phone] = map[string]interface{}{
|
|
1084
|
+
"jid": p.JID.String(),
|
|
1085
|
+
"phone": phone,
|
|
1086
|
+
"is_admin": p.IsAdmin,
|
|
1087
|
+
"is_super_admin": p.IsSuperAdmin,
|
|
1088
|
+
}
|
|
1089
|
+
// Build reverse index: phone -> groups
|
|
1090
|
+
phoneToGroups[phone] = append(phoneToGroups[phone], g.JID.String())
|
|
1091
|
+
}
|
|
1092
|
+
|
|
1093
|
+
groupsMap[g.JID.String()] = map[string]interface{}{
|
|
1094
|
+
"jid": g.JID.String(),
|
|
1095
|
+
"name": g.Name,
|
|
1096
|
+
"topic": g.Topic,
|
|
1097
|
+
"owner": g.OwnerJID.String(),
|
|
1098
|
+
"size": len(g.Participants),
|
|
1099
|
+
"is_announce": g.IsAnnounce,
|
|
1100
|
+
"is_locked": g.IsLocked,
|
|
1101
|
+
"created_at": g.GroupCreated,
|
|
1102
|
+
"participants": participantsMap,
|
|
1103
|
+
}
|
|
1104
|
+
}
|
|
1105
|
+
|
|
1106
|
+
data := map[string]interface{}{
|
|
1107
|
+
"generated_at": time.Now().UTC(),
|
|
1108
|
+
"device_id": s.client.Store.ID.String(),
|
|
1109
|
+
"groups": groupsMap,
|
|
1110
|
+
"phone_to_groups": phoneToGroups,
|
|
1111
|
+
}
|
|
1112
|
+
|
|
1113
|
+
// Ensure data directory exists
|
|
1114
|
+
if err := os.MkdirAll("data", 0755); err != nil {
|
|
1115
|
+
return fmt.Errorf("failed to create data directory: %w", err)
|
|
1116
|
+
}
|
|
1117
|
+
|
|
1118
|
+
// Write JSON file with pretty formatting
|
|
1119
|
+
jsonData, err := json.MarshalIndent(data, "", " ")
|
|
1120
|
+
if err != nil {
|
|
1121
|
+
return fmt.Errorf("failed to marshal JSON: %w", err)
|
|
1122
|
+
}
|
|
1123
|
+
|
|
1124
|
+
if err := os.WriteFile("data/groups.json", jsonData, 0644); err != nil {
|
|
1125
|
+
return fmt.Errorf("failed to write file: %w", err)
|
|
1126
|
+
}
|
|
1127
|
+
|
|
1128
|
+
s.logger.Infof("Generated data/groups.json with %d groups", len(groups))
|
|
1129
|
+
return nil
|
|
1130
|
+
}
|
|
1131
|
+
|
|
1132
|
+
// GetGroups returns all groups the user is a member of
|
|
1133
|
+
func (s *Service) GetGroups() ([]GroupInfo, error) {
|
|
1134
|
+
if !s.client.IsConnected() {
|
|
1135
|
+
return nil, fmt.Errorf("WhatsApp not connected")
|
|
1136
|
+
}
|
|
1137
|
+
|
|
1138
|
+
ctx := context.Background()
|
|
1139
|
+
groups, err := s.client.GetJoinedGroups(ctx)
|
|
1140
|
+
if err != nil {
|
|
1141
|
+
s.logger.Errorf("Failed to get joined groups: %v", err)
|
|
1142
|
+
return nil, fmt.Errorf("failed to get groups: %w", err)
|
|
1143
|
+
}
|
|
1144
|
+
|
|
1145
|
+
result := make([]GroupInfo, 0, len(groups))
|
|
1146
|
+
for _, g := range groups {
|
|
1147
|
+
participants := make([]GroupParticipant, 0, len(g.Participants))
|
|
1148
|
+
for _, p := range g.Participants {
|
|
1149
|
+
phone := s.resolveParticipantPhone(ctx, p)
|
|
1150
|
+
participants = append(participants, GroupParticipant{
|
|
1151
|
+
JID: p.JID.String(),
|
|
1152
|
+
Phone: phone,
|
|
1153
|
+
Name: s.GetContactName(phone),
|
|
1154
|
+
IsAdmin: p.IsAdmin,
|
|
1155
|
+
IsSuperAdmin: p.IsSuperAdmin,
|
|
1156
|
+
})
|
|
1157
|
+
}
|
|
1158
|
+
|
|
1159
|
+
result = append(result, GroupInfo{
|
|
1160
|
+
JID: g.JID.String(),
|
|
1161
|
+
Name: g.Name,
|
|
1162
|
+
Topic: g.Topic,
|
|
1163
|
+
Owner: g.OwnerJID.String(),
|
|
1164
|
+
Participants: participants,
|
|
1165
|
+
CreatedAt: g.GroupCreated,
|
|
1166
|
+
Size: len(g.Participants),
|
|
1167
|
+
IsAnnounce: g.IsAnnounce,
|
|
1168
|
+
IsLocked: g.IsLocked,
|
|
1169
|
+
IsCommunity: g.IsParent,
|
|
1170
|
+
})
|
|
1171
|
+
}
|
|
1172
|
+
|
|
1173
|
+
s.logger.Infof("Retrieved %d groups", len(result))
|
|
1174
|
+
return result, nil
|
|
1175
|
+
}
|
|
1176
|
+
|
|
1177
|
+
// GetGroupInfo returns detailed information about a specific group
|
|
1178
|
+
func (s *Service) GetGroupInfo(groupJID string) (*GroupInfo, error) {
|
|
1179
|
+
if !s.client.IsConnected() {
|
|
1180
|
+
return nil, fmt.Errorf("WhatsApp not connected")
|
|
1181
|
+
}
|
|
1182
|
+
|
|
1183
|
+
jid, err := types.ParseJID(groupJID)
|
|
1184
|
+
if err != nil {
|
|
1185
|
+
return nil, fmt.Errorf("invalid group JID: %w", err)
|
|
1186
|
+
}
|
|
1187
|
+
|
|
1188
|
+
ctx := context.Background()
|
|
1189
|
+
info, err := s.client.GetGroupInfo(ctx, jid)
|
|
1190
|
+
if err != nil {
|
|
1191
|
+
s.logger.Errorf("Failed to get group info for %s: %v", groupJID, err)
|
|
1192
|
+
return nil, fmt.Errorf("failed to get group info: %w", err)
|
|
1193
|
+
}
|
|
1194
|
+
|
|
1195
|
+
participants := make([]GroupParticipant, 0, len(info.Participants))
|
|
1196
|
+
for _, p := range info.Participants {
|
|
1197
|
+
phone := s.resolveParticipantPhone(ctx, p)
|
|
1198
|
+
participants = append(participants, GroupParticipant{
|
|
1199
|
+
JID: p.JID.String(),
|
|
1200
|
+
Phone: phone,
|
|
1201
|
+
Name: s.GetContactName(phone),
|
|
1202
|
+
IsAdmin: p.IsAdmin,
|
|
1203
|
+
IsSuperAdmin: p.IsSuperAdmin,
|
|
1204
|
+
})
|
|
1205
|
+
}
|
|
1206
|
+
|
|
1207
|
+
result := &GroupInfo{
|
|
1208
|
+
JID: info.JID.String(),
|
|
1209
|
+
Name: info.Name,
|
|
1210
|
+
Topic: info.Topic,
|
|
1211
|
+
Owner: info.OwnerJID.String(),
|
|
1212
|
+
Participants: participants,
|
|
1213
|
+
CreatedAt: info.GroupCreated,
|
|
1214
|
+
Size: len(info.Participants),
|
|
1215
|
+
IsAnnounce: info.IsAnnounce,
|
|
1216
|
+
IsLocked: info.IsLocked,
|
|
1217
|
+
IsCommunity: info.IsParent,
|
|
1218
|
+
}
|
|
1219
|
+
|
|
1220
|
+
s.logger.Infof("Retrieved info for group %s (%s)", info.Name, groupJID)
|
|
1221
|
+
return result, nil
|
|
1222
|
+
}
|
|
1223
|
+
|
|
1224
|
+
// UpdateGroupInfo updates the name and/or topic of a group
|
|
1225
|
+
func (s *Service) UpdateGroupInfo(groupJID, name, topic string) error {
|
|
1226
|
+
if !s.client.IsConnected() {
|
|
1227
|
+
return fmt.Errorf("WhatsApp not connected")
|
|
1228
|
+
}
|
|
1229
|
+
|
|
1230
|
+
jid, err := types.ParseJID(groupJID)
|
|
1231
|
+
if err != nil {
|
|
1232
|
+
return fmt.Errorf("invalid group JID: %w", err)
|
|
1233
|
+
}
|
|
1234
|
+
|
|
1235
|
+
ctx := context.Background()
|
|
1236
|
+
|
|
1237
|
+
// Update name if provided
|
|
1238
|
+
if name != "" {
|
|
1239
|
+
err = s.client.SetGroupName(ctx, jid, name)
|
|
1240
|
+
if err != nil {
|
|
1241
|
+
s.logger.Errorf("Failed to update group name for %s: %v", groupJID, err)
|
|
1242
|
+
return fmt.Errorf("failed to update group name: %w", err)
|
|
1243
|
+
}
|
|
1244
|
+
s.logger.Infof("Updated group name for %s to: %s", groupJID, name)
|
|
1245
|
+
}
|
|
1246
|
+
|
|
1247
|
+
// Update topic/description if provided
|
|
1248
|
+
if topic != "" {
|
|
1249
|
+
// SetGroupTopic requires: ctx, jid, previousTopicID, newTopicID, topic
|
|
1250
|
+
// Using empty strings for topic IDs as they're optional for setting new topic
|
|
1251
|
+
err = s.client.SetGroupTopic(ctx, jid, "", "", topic)
|
|
1252
|
+
if err != nil {
|
|
1253
|
+
s.logger.Errorf("Failed to update group topic for %s: %v", groupJID, err)
|
|
1254
|
+
return fmt.Errorf("failed to update group topic: %w", err)
|
|
1255
|
+
}
|
|
1256
|
+
s.logger.Infof("Updated group topic for %s", groupJID)
|
|
1257
|
+
}
|
|
1258
|
+
|
|
1259
|
+
return nil
|
|
1260
|
+
}
|
|
1261
|
+
|
|
1262
|
+
// CheckContacts checks if phone numbers are registered on WhatsApp
|
|
1263
|
+
func (s *Service) CheckContacts(phones []string) ([]ContactCheckResult, error) {
|
|
1264
|
+
if !s.client.IsConnected() {
|
|
1265
|
+
return nil, fmt.Errorf("WhatsApp not connected")
|
|
1266
|
+
}
|
|
1267
|
+
|
|
1268
|
+
ctx := context.Background()
|
|
1269
|
+
results, err := s.client.IsOnWhatsApp(ctx, phones)
|
|
1270
|
+
if err != nil {
|
|
1271
|
+
s.logger.Errorf("Failed to check contacts: %v", err)
|
|
1272
|
+
return nil, fmt.Errorf("failed to check contacts: %w", err)
|
|
1273
|
+
}
|
|
1274
|
+
|
|
1275
|
+
response := make([]ContactCheckResult, 0, len(results))
|
|
1276
|
+
for _, r := range results {
|
|
1277
|
+
result := ContactCheckResult{
|
|
1278
|
+
Query: r.Query,
|
|
1279
|
+
IsRegistered: r.IsIn,
|
|
1280
|
+
}
|
|
1281
|
+
if r.IsIn && r.JID.User != "" {
|
|
1282
|
+
result.JID = r.JID.String()
|
|
1283
|
+
}
|
|
1284
|
+
if r.VerifiedName != nil {
|
|
1285
|
+
result.IsBusiness = true
|
|
1286
|
+
result.BusinessName = r.VerifiedName.Details.GetVerifiedName()
|
|
1287
|
+
}
|
|
1288
|
+
response = append(response, result)
|
|
1289
|
+
}
|
|
1290
|
+
|
|
1291
|
+
s.logger.Infof("Checked %d phone numbers, %d registered", len(phones), countRegistered(response))
|
|
1292
|
+
return response, nil
|
|
1293
|
+
}
|
|
1294
|
+
|
|
1295
|
+
func countRegistered(results []ContactCheckResult) int {
|
|
1296
|
+
count := 0
|
|
1297
|
+
for _, r := range results {
|
|
1298
|
+
if r.IsRegistered {
|
|
1299
|
+
count++
|
|
1300
|
+
}
|
|
1301
|
+
}
|
|
1302
|
+
return count
|
|
1303
|
+
}
|
|
1304
|
+
|
|
1305
|
+
// GetProfilePicture gets profile picture for a JID
|
|
1306
|
+
func (s *Service) GetProfilePicture(jidStr string, preview bool) (*ProfilePicResult, error) {
|
|
1307
|
+
if !s.client.IsConnected() {
|
|
1308
|
+
return nil, fmt.Errorf("WhatsApp not connected")
|
|
1309
|
+
}
|
|
1310
|
+
|
|
1311
|
+
jid, err := types.ParseJID(jidStr)
|
|
1312
|
+
if err != nil {
|
|
1313
|
+
return nil, fmt.Errorf("invalid JID: %w", err)
|
|
1314
|
+
}
|
|
1315
|
+
|
|
1316
|
+
ctx := context.Background()
|
|
1317
|
+
params := &whatsmeow.GetProfilePictureParams{
|
|
1318
|
+
Preview: preview,
|
|
1319
|
+
}
|
|
1320
|
+
|
|
1321
|
+
info, err := s.client.GetProfilePictureInfo(ctx, jid, params)
|
|
1322
|
+
if err != nil {
|
|
1323
|
+
// Profile picture may not exist - this is not an error
|
|
1324
|
+
errStr := err.Error()
|
|
1325
|
+
if strings.Contains(errStr, "item-not-found") || strings.Contains(errStr, "not-authorized") {
|
|
1326
|
+
return &ProfilePicResult{Exists: false}, nil
|
|
1327
|
+
}
|
|
1328
|
+
s.logger.Errorf("Failed to get profile picture for %s: %v", jidStr, err)
|
|
1329
|
+
return nil, fmt.Errorf("failed to get profile picture: %w", err)
|
|
1330
|
+
}
|
|
1331
|
+
|
|
1332
|
+
result := &ProfilePicResult{
|
|
1333
|
+
Exists: true,
|
|
1334
|
+
URL: info.URL,
|
|
1335
|
+
ID: info.ID,
|
|
1336
|
+
}
|
|
1337
|
+
|
|
1338
|
+
s.logger.Infof("Retrieved profile picture for %s", jidStr)
|
|
1339
|
+
return result, nil
|
|
1340
|
+
}
|
|
1341
|
+
|
|
1342
|
+
// SendTyping sends typing indicator to a chat
|
|
1343
|
+
func (s *Service) SendTyping(jidStr, state, media string) error {
|
|
1344
|
+
if !s.client.IsConnected() {
|
|
1345
|
+
return fmt.Errorf("WhatsApp not connected")
|
|
1346
|
+
}
|
|
1347
|
+
|
|
1348
|
+
jid, err := types.ParseJID(jidStr)
|
|
1349
|
+
if err != nil {
|
|
1350
|
+
return fmt.Errorf("invalid JID: %w", err)
|
|
1351
|
+
}
|
|
1352
|
+
|
|
1353
|
+
// Convert state string to ChatPresence
|
|
1354
|
+
var chatPresence types.ChatPresence
|
|
1355
|
+
switch state {
|
|
1356
|
+
case "composing":
|
|
1357
|
+
chatPresence = types.ChatPresenceComposing
|
|
1358
|
+
case "paused":
|
|
1359
|
+
chatPresence = types.ChatPresencePaused
|
|
1360
|
+
default:
|
|
1361
|
+
return fmt.Errorf("invalid state: %s (must be 'composing' or 'paused')", state)
|
|
1362
|
+
}
|
|
1363
|
+
|
|
1364
|
+
// Convert media string to ChatPresenceMedia
|
|
1365
|
+
var chatMedia types.ChatPresenceMedia
|
|
1366
|
+
switch media {
|
|
1367
|
+
case "", "text":
|
|
1368
|
+
chatMedia = types.ChatPresenceMediaText
|
|
1369
|
+
case "audio":
|
|
1370
|
+
chatMedia = types.ChatPresenceMediaAudio
|
|
1371
|
+
default:
|
|
1372
|
+
return fmt.Errorf("invalid media: %s (must be 'text' or 'audio')", media)
|
|
1373
|
+
}
|
|
1374
|
+
|
|
1375
|
+
ctx := context.Background()
|
|
1376
|
+
err = s.client.SendChatPresence(ctx, jid, chatPresence, chatMedia)
|
|
1377
|
+
if err != nil {
|
|
1378
|
+
s.logger.Errorf("Failed to send typing indicator to %s: %v", jidStr, err)
|
|
1379
|
+
return fmt.Errorf("failed to send typing indicator: %w", err)
|
|
1380
|
+
}
|
|
1381
|
+
|
|
1382
|
+
s.logger.Infof("Sent typing indicator (%s) to %s", state, jidStr)
|
|
1383
|
+
return nil
|
|
1384
|
+
}
|
|
1385
|
+
|
|
1386
|
+
// SetPresence sets online/offline presence status
|
|
1387
|
+
func (s *Service) SetPresence(status string) error {
|
|
1388
|
+
if !s.client.IsConnected() {
|
|
1389
|
+
return fmt.Errorf("WhatsApp not connected")
|
|
1390
|
+
}
|
|
1391
|
+
|
|
1392
|
+
var presence types.Presence
|
|
1393
|
+
switch status {
|
|
1394
|
+
case "available", "online":
|
|
1395
|
+
presence = types.PresenceAvailable
|
|
1396
|
+
case "unavailable", "offline":
|
|
1397
|
+
presence = types.PresenceUnavailable
|
|
1398
|
+
default:
|
|
1399
|
+
return fmt.Errorf("invalid status: %s (must be 'available' or 'unavailable')", status)
|
|
1400
|
+
}
|
|
1401
|
+
|
|
1402
|
+
ctx := context.Background()
|
|
1403
|
+
err := s.client.SendPresence(ctx, presence)
|
|
1404
|
+
if err != nil {
|
|
1405
|
+
s.logger.Errorf("Failed to set presence to %s: %v", status, err)
|
|
1406
|
+
return fmt.Errorf("failed to set presence: %w", err)
|
|
1407
|
+
}
|
|
1408
|
+
|
|
1409
|
+
s.logger.Infof("Set presence to %s", status)
|
|
1410
|
+
return nil
|
|
1411
|
+
}
|
|
1412
|
+
|
|
1413
|
+
// MarkRead marks messages as read
|
|
1414
|
+
func (s *Service) MarkRead(messageIDs []string, chatJID, senderJID string) error {
|
|
1415
|
+
if !s.client.IsConnected() {
|
|
1416
|
+
return fmt.Errorf("WhatsApp not connected")
|
|
1417
|
+
}
|
|
1418
|
+
|
|
1419
|
+
if len(messageIDs) == 0 {
|
|
1420
|
+
return fmt.Errorf("at least one message_id is required")
|
|
1421
|
+
}
|
|
1422
|
+
|
|
1423
|
+
chat, err := types.ParseJID(chatJID)
|
|
1424
|
+
if err != nil {
|
|
1425
|
+
return fmt.Errorf("invalid chat_jid: %w", err)
|
|
1426
|
+
}
|
|
1427
|
+
|
|
1428
|
+
// For DMs, sender is the same as chat
|
|
1429
|
+
// For groups, sender is the actual message sender
|
|
1430
|
+
sender := chat
|
|
1431
|
+
if senderJID != "" {
|
|
1432
|
+
sender, err = types.ParseJID(senderJID)
|
|
1433
|
+
if err != nil {
|
|
1434
|
+
return fmt.Errorf("invalid sender_jid: %w", err)
|
|
1435
|
+
}
|
|
1436
|
+
}
|
|
1437
|
+
|
|
1438
|
+
ctx := context.Background()
|
|
1439
|
+
err = s.client.MarkRead(ctx, messageIDs, time.Now(), chat, sender)
|
|
1440
|
+
if err != nil {
|
|
1441
|
+
s.logger.Errorf("Failed to mark messages as read: %v", err)
|
|
1442
|
+
return fmt.Errorf("failed to mark messages as read: %w", err)
|
|
1443
|
+
}
|
|
1444
|
+
|
|
1445
|
+
s.logger.Infof("Marked %d message(s) as read in %s", len(messageIDs), chatJID)
|
|
1446
|
+
return nil
|
|
1447
|
+
}
|
|
1448
|
+
|
|
1449
|
+
// normalizeToJID converts a phone number or JID string to types.JID
|
|
1450
|
+
func (s *Service) normalizeToJID(input string) (types.JID, error) {
|
|
1451
|
+
// If it's already a JID (contains @), parse it directly
|
|
1452
|
+
if strings.Contains(input, "@") {
|
|
1453
|
+
return types.ParseJID(input)
|
|
1454
|
+
}
|
|
1455
|
+
|
|
1456
|
+
// Otherwise, treat as phone number and create user JID
|
|
1457
|
+
// Remove any non-digit characters
|
|
1458
|
+
phone := strings.Map(func(r rune) rune {
|
|
1459
|
+
if r >= '0' && r <= '9' {
|
|
1460
|
+
return r
|
|
1461
|
+
}
|
|
1462
|
+
return -1
|
|
1463
|
+
}, input)
|
|
1464
|
+
|
|
1465
|
+
if phone == "" {
|
|
1466
|
+
return types.JID{}, fmt.Errorf("invalid phone number: %s", input)
|
|
1467
|
+
}
|
|
1468
|
+
|
|
1469
|
+
return types.NewJID(phone, types.DefaultUserServer), nil
|
|
1470
|
+
}
|
|
1471
|
+
|
|
1472
|
+
// AddGroupParticipants adds participants to a group
|
|
1473
|
+
func (s *Service) AddGroupParticipants(groupJID string, participants []string) (*GroupParticipantsResult, error) {
|
|
1474
|
+
if !s.client.IsConnected() {
|
|
1475
|
+
return nil, fmt.Errorf("WhatsApp not connected")
|
|
1476
|
+
}
|
|
1477
|
+
|
|
1478
|
+
jid, err := types.ParseJID(groupJID)
|
|
1479
|
+
if err != nil {
|
|
1480
|
+
return nil, fmt.Errorf("invalid group JID: %w", err)
|
|
1481
|
+
}
|
|
1482
|
+
|
|
1483
|
+
// Convert phone numbers/JIDs to types.JID slice
|
|
1484
|
+
participantJIDs := make([]types.JID, 0, len(participants))
|
|
1485
|
+
for _, p := range participants {
|
|
1486
|
+
pJID, err := s.normalizeToJID(p)
|
|
1487
|
+
if err != nil {
|
|
1488
|
+
s.logger.Warnf("Invalid participant %s: %v", p, err)
|
|
1489
|
+
continue
|
|
1490
|
+
}
|
|
1491
|
+
participantJIDs = append(participantJIDs, pJID)
|
|
1492
|
+
}
|
|
1493
|
+
|
|
1494
|
+
if len(participantJIDs) == 0 {
|
|
1495
|
+
return nil, fmt.Errorf("no valid participants provided")
|
|
1496
|
+
}
|
|
1497
|
+
|
|
1498
|
+
ctx := context.Background()
|
|
1499
|
+
results, err := s.client.UpdateGroupParticipants(ctx, jid, participantJIDs, whatsmeow.ParticipantChangeAdd)
|
|
1500
|
+
if err != nil {
|
|
1501
|
+
s.logger.Errorf("Failed to add participants to %s: %v", groupJID, err)
|
|
1502
|
+
return nil, fmt.Errorf("failed to add participants: %w", err)
|
|
1503
|
+
}
|
|
1504
|
+
|
|
1505
|
+
// Build result
|
|
1506
|
+
response := &GroupParticipantsResult{
|
|
1507
|
+
GroupID: groupJID,
|
|
1508
|
+
Action: "add",
|
|
1509
|
+
Results: make([]GroupParticipantChangeResult, 0, len(results)),
|
|
1510
|
+
}
|
|
1511
|
+
|
|
1512
|
+
for _, r := range results {
|
|
1513
|
+
result := GroupParticipantChangeResult{
|
|
1514
|
+
JID: r.JID.String(),
|
|
1515
|
+
Phone: r.JID.User,
|
|
1516
|
+
Success: r.Error == 0, // 0 means success
|
|
1517
|
+
}
|
|
1518
|
+
if r.Error != 0 {
|
|
1519
|
+
result.Error = fmt.Sprintf("error code: %d", r.Error)
|
|
1520
|
+
response.Failed++
|
|
1521
|
+
} else {
|
|
1522
|
+
response.Added++
|
|
1523
|
+
}
|
|
1524
|
+
response.Results = append(response.Results, result)
|
|
1525
|
+
}
|
|
1526
|
+
|
|
1527
|
+
s.logger.Infof("Added %d participants to group %s (failed: %d)", response.Added, groupJID, response.Failed)
|
|
1528
|
+
return response, nil
|
|
1529
|
+
}
|
|
1530
|
+
|
|
1531
|
+
// RemoveGroupParticipants removes participants from a group
|
|
1532
|
+
func (s *Service) RemoveGroupParticipants(groupJID string, participants []string) (*GroupParticipantsResult, error) {
|
|
1533
|
+
if !s.client.IsConnected() {
|
|
1534
|
+
return nil, fmt.Errorf("WhatsApp not connected")
|
|
1535
|
+
}
|
|
1536
|
+
|
|
1537
|
+
jid, err := types.ParseJID(groupJID)
|
|
1538
|
+
if err != nil {
|
|
1539
|
+
return nil, fmt.Errorf("invalid group JID: %w", err)
|
|
1540
|
+
}
|
|
1541
|
+
|
|
1542
|
+
// Convert phone numbers/JIDs to types.JID slice
|
|
1543
|
+
participantJIDs := make([]types.JID, 0, len(participants))
|
|
1544
|
+
for _, p := range participants {
|
|
1545
|
+
pJID, err := s.normalizeToJID(p)
|
|
1546
|
+
if err != nil {
|
|
1547
|
+
s.logger.Warnf("Invalid participant %s: %v", p, err)
|
|
1548
|
+
continue
|
|
1549
|
+
}
|
|
1550
|
+
participantJIDs = append(participantJIDs, pJID)
|
|
1551
|
+
}
|
|
1552
|
+
|
|
1553
|
+
if len(participantJIDs) == 0 {
|
|
1554
|
+
return nil, fmt.Errorf("no valid participants provided")
|
|
1555
|
+
}
|
|
1556
|
+
|
|
1557
|
+
ctx := context.Background()
|
|
1558
|
+
results, err := s.client.UpdateGroupParticipants(ctx, jid, participantJIDs, whatsmeow.ParticipantChangeRemove)
|
|
1559
|
+
if err != nil {
|
|
1560
|
+
s.logger.Errorf("Failed to remove participants from %s: %v", groupJID, err)
|
|
1561
|
+
return nil, fmt.Errorf("failed to remove participants: %w", err)
|
|
1562
|
+
}
|
|
1563
|
+
|
|
1564
|
+
// Build result
|
|
1565
|
+
response := &GroupParticipantsResult{
|
|
1566
|
+
GroupID: groupJID,
|
|
1567
|
+
Action: "remove",
|
|
1568
|
+
Results: make([]GroupParticipantChangeResult, 0, len(results)),
|
|
1569
|
+
}
|
|
1570
|
+
|
|
1571
|
+
for _, r := range results {
|
|
1572
|
+
result := GroupParticipantChangeResult{
|
|
1573
|
+
JID: r.JID.String(),
|
|
1574
|
+
Phone: r.JID.User,
|
|
1575
|
+
Success: r.Error == 0,
|
|
1576
|
+
}
|
|
1577
|
+
if r.Error != 0 {
|
|
1578
|
+
result.Error = fmt.Sprintf("error code: %d", r.Error)
|
|
1579
|
+
response.Failed++
|
|
1580
|
+
} else {
|
|
1581
|
+
response.Removed++
|
|
1582
|
+
}
|
|
1583
|
+
response.Results = append(response.Results, result)
|
|
1584
|
+
}
|
|
1585
|
+
|
|
1586
|
+
s.logger.Infof("Removed %d participants from group %s (failed: %d)", response.Removed, groupJID, response.Failed)
|
|
1587
|
+
return response, nil
|
|
1588
|
+
}
|
|
1589
|
+
|
|
1590
|
+
// GetGroupInviteLink gets the invite link for a group
|
|
1591
|
+
func (s *Service) GetGroupInviteLink(groupJID string) (*GroupInviteLinkResult, error) {
|
|
1592
|
+
if !s.client.IsConnected() {
|
|
1593
|
+
return nil, fmt.Errorf("WhatsApp not connected")
|
|
1594
|
+
}
|
|
1595
|
+
|
|
1596
|
+
jid, err := types.ParseJID(groupJID)
|
|
1597
|
+
if err != nil {
|
|
1598
|
+
return nil, fmt.Errorf("invalid group JID: %w", err)
|
|
1599
|
+
}
|
|
1600
|
+
|
|
1601
|
+
ctx := context.Background()
|
|
1602
|
+
link, err := s.client.GetGroupInviteLink(ctx, jid, false)
|
|
1603
|
+
if err != nil {
|
|
1604
|
+
s.logger.Errorf("Failed to get invite link for %s: %v", groupJID, err)
|
|
1605
|
+
return nil, fmt.Errorf("failed to get invite link: %w", err)
|
|
1606
|
+
}
|
|
1607
|
+
|
|
1608
|
+
s.logger.Infof("Retrieved invite link for group %s", groupJID)
|
|
1609
|
+
return &GroupInviteLinkResult{
|
|
1610
|
+
GroupID: groupJID,
|
|
1611
|
+
InviteLink: link,
|
|
1612
|
+
Revoked: false,
|
|
1613
|
+
}, nil
|
|
1614
|
+
}
|
|
1615
|
+
|
|
1616
|
+
// RevokeGroupInviteLink revokes the current invite link and generates a new one
|
|
1617
|
+
func (s *Service) RevokeGroupInviteLink(groupJID string) (*GroupInviteLinkResult, error) {
|
|
1618
|
+
if !s.client.IsConnected() {
|
|
1619
|
+
return nil, fmt.Errorf("WhatsApp not connected")
|
|
1620
|
+
}
|
|
1621
|
+
|
|
1622
|
+
jid, err := types.ParseJID(groupJID)
|
|
1623
|
+
if err != nil {
|
|
1624
|
+
return nil, fmt.Errorf("invalid group JID: %w", err)
|
|
1625
|
+
}
|
|
1626
|
+
|
|
1627
|
+
ctx := context.Background()
|
|
1628
|
+
// reset=true revokes the old link and generates new one
|
|
1629
|
+
link, err := s.client.GetGroupInviteLink(ctx, jid, true)
|
|
1630
|
+
if err != nil {
|
|
1631
|
+
s.logger.Errorf("Failed to revoke invite link for %s: %v", groupJID, err)
|
|
1632
|
+
return nil, fmt.Errorf("failed to revoke invite link: %w", err)
|
|
1633
|
+
}
|
|
1634
|
+
|
|
1635
|
+
s.logger.Infof("Revoked and regenerated invite link for group %s", groupJID)
|
|
1636
|
+
return &GroupInviteLinkResult{
|
|
1637
|
+
GroupID: groupJID,
|
|
1638
|
+
InviteLink: link,
|
|
1639
|
+
Revoked: true,
|
|
1640
|
+
}, nil
|
|
1641
|
+
}
|
|
1642
|
+
|
|
1643
|
+
// GetCurrentQRCode returns the last generated QR code if recent
|
|
1644
|
+
func (s *Service) GetCurrentQRCode() *QRCodeData {
|
|
1645
|
+
s.mu.Lock()
|
|
1646
|
+
defer s.mu.Unlock()
|
|
1647
|
+
|
|
1648
|
+
if s.lastQRCode == nil {
|
|
1649
|
+
return nil
|
|
1650
|
+
}
|
|
1651
|
+
|
|
1652
|
+
// Return QR if it was generated within last 60 seconds
|
|
1653
|
+
if time.Since(s.lastQRCodeTime) > 60*time.Second {
|
|
1654
|
+
return nil
|
|
1655
|
+
}
|
|
1656
|
+
|
|
1657
|
+
return s.lastQRCode
|
|
1658
|
+
}
|
|
1659
|
+
|
|
1660
|
+
// ============================================================================
|
|
1661
|
+
// Rate Limiting Methods
|
|
1662
|
+
// ============================================================================
|
|
1663
|
+
|
|
1664
|
+
// GetRateLimitConfig returns the current rate limit configuration
|
|
1665
|
+
func (s *Service) GetRateLimitConfig() *RateLimitConfig {
|
|
1666
|
+
s.rateMu.Lock()
|
|
1667
|
+
defer s.rateMu.Unlock()
|
|
1668
|
+
return s.rateLimitConfig
|
|
1669
|
+
}
|
|
1670
|
+
|
|
1671
|
+
// SetRateLimitConfig updates the rate limit configuration
|
|
1672
|
+
func (s *Service) SetRateLimitConfig(config *RateLimitConfig) error {
|
|
1673
|
+
s.rateMu.Lock()
|
|
1674
|
+
defer s.rateMu.Unlock()
|
|
1675
|
+
|
|
1676
|
+
// Validate config
|
|
1677
|
+
if config.MinDelayMs < 0 {
|
|
1678
|
+
return fmt.Errorf("min_delay_ms cannot be negative")
|
|
1679
|
+
}
|
|
1680
|
+
if config.MaxDelayMs < config.MinDelayMs {
|
|
1681
|
+
config.MaxDelayMs = config.MinDelayMs
|
|
1682
|
+
}
|
|
1683
|
+
if config.MaxMessagesPerMinute < 0 {
|
|
1684
|
+
config.MaxMessagesPerMinute = 0
|
|
1685
|
+
}
|
|
1686
|
+
if config.MaxMessagesPerHour < 0 {
|
|
1687
|
+
config.MaxMessagesPerHour = 0
|
|
1688
|
+
}
|
|
1689
|
+
if config.MaxNewContactsPerDay < 0 {
|
|
1690
|
+
config.MaxNewContactsPerDay = 0
|
|
1691
|
+
}
|
|
1692
|
+
|
|
1693
|
+
s.rateLimitConfig = config
|
|
1694
|
+
s.logger.Infof("Rate limit config updated: enabled=%v, delay=%d-%dms, typing=%v",
|
|
1695
|
+
config.Enabled, config.MinDelayMs, config.MaxDelayMs, config.SimulateTyping)
|
|
1696
|
+
return nil
|
|
1697
|
+
}
|
|
1698
|
+
|
|
1699
|
+
// GetRateLimitStats returns current rate limiting statistics
|
|
1700
|
+
func (s *Service) GetRateLimitStats() *RateLimitStats {
|
|
1701
|
+
s.rateMu.Lock()
|
|
1702
|
+
defer s.rateMu.Unlock()
|
|
1703
|
+
|
|
1704
|
+
// Reset daily counters if needed
|
|
1705
|
+
s.resetDailyCountersIfNeeded()
|
|
1706
|
+
|
|
1707
|
+
// Calculate stats from sliding window
|
|
1708
|
+
now := time.Now()
|
|
1709
|
+
minuteAgo := now.Add(-1 * time.Minute)
|
|
1710
|
+
hourAgo := now.Add(-1 * time.Hour)
|
|
1711
|
+
|
|
1712
|
+
minuteCount := 0
|
|
1713
|
+
hourCount := 0
|
|
1714
|
+
for _, t := range s.messageTimes {
|
|
1715
|
+
if t.After(minuteAgo) {
|
|
1716
|
+
minuteCount++
|
|
1717
|
+
}
|
|
1718
|
+
if t.After(hourAgo) {
|
|
1719
|
+
hourCount++
|
|
1720
|
+
}
|
|
1721
|
+
}
|
|
1722
|
+
|
|
1723
|
+
// Count new contacts today
|
|
1724
|
+
newContactsCount := 0
|
|
1725
|
+
for _, t := range s.newContactsToday {
|
|
1726
|
+
if t.After(s.dailyResetTime) {
|
|
1727
|
+
newContactsCount++
|
|
1728
|
+
}
|
|
1729
|
+
}
|
|
1730
|
+
|
|
1731
|
+
// Calculate response rate (simplified - based on messages received vs sent)
|
|
1732
|
+
responseRate := 0.0
|
|
1733
|
+
if s.rateLimitStats.MessagesSentToday > 0 {
|
|
1734
|
+
responseRate = float64(s.rateLimitStats.ResponsesReceived) / float64(s.rateLimitStats.MessagesSentToday)
|
|
1735
|
+
}
|
|
1736
|
+
|
|
1737
|
+
// Calculate next allowed time
|
|
1738
|
+
nextAllowed := s.rateLimitStats.LastMessageTime.Add(time.Duration(s.rateLimitConfig.MinDelayMs) * time.Millisecond)
|
|
1739
|
+
|
|
1740
|
+
return &RateLimitStats{
|
|
1741
|
+
MessagesSentLastMinute: minuteCount,
|
|
1742
|
+
MessagesSentLastHour: hourCount,
|
|
1743
|
+
MessagesSentToday: s.rateLimitStats.MessagesSentToday,
|
|
1744
|
+
NewContactsToday: newContactsCount,
|
|
1745
|
+
ResponsesReceived: s.rateLimitStats.ResponsesReceived,
|
|
1746
|
+
ResponseRate: responseRate,
|
|
1747
|
+
IsPaused: s.rateLimitStats.IsPaused,
|
|
1748
|
+
PauseReason: s.rateLimitStats.PauseReason,
|
|
1749
|
+
LastMessageTime: s.rateLimitStats.LastMessageTime,
|
|
1750
|
+
NextAllowedTime: nextAllowed,
|
|
1751
|
+
}
|
|
1752
|
+
}
|
|
1753
|
+
|
|
1754
|
+
// resetDailyCountersIfNeeded resets daily counters at midnight
|
|
1755
|
+
func (s *Service) resetDailyCountersIfNeeded() {
|
|
1756
|
+
now := time.Now()
|
|
1757
|
+
// Check if we've crossed midnight since last reset
|
|
1758
|
+
if now.Day() != s.dailyResetTime.Day() || now.Month() != s.dailyResetTime.Month() {
|
|
1759
|
+
s.rateLimitStats.MessagesSentToday = 0
|
|
1760
|
+
s.rateLimitStats.ResponsesReceived = 0
|
|
1761
|
+
s.newContactsToday = make(map[string]time.Time)
|
|
1762
|
+
s.dailyResetTime = now
|
|
1763
|
+
s.logger.Info("Daily rate limit counters reset")
|
|
1764
|
+
}
|
|
1765
|
+
}
|
|
1766
|
+
|
|
1767
|
+
// CheckRateLimit checks rate limits and waits if necessary. Returns error only for hard limits.
|
|
1768
|
+
func (s *Service) CheckRateLimit(recipient string) error {
|
|
1769
|
+
s.rateMu.Lock()
|
|
1770
|
+
config := s.rateLimitConfig
|
|
1771
|
+
s.rateMu.Unlock()
|
|
1772
|
+
|
|
1773
|
+
if !config.Enabled {
|
|
1774
|
+
return nil
|
|
1775
|
+
}
|
|
1776
|
+
|
|
1777
|
+
// Keep checking and waiting until we're within limits
|
|
1778
|
+
for {
|
|
1779
|
+
waitDuration, hardError := s.checkRateLimitInternal(recipient)
|
|
1780
|
+
if hardError != nil {
|
|
1781
|
+
return hardError // New contacts limit or paused - can't wait
|
|
1782
|
+
}
|
|
1783
|
+
if waitDuration <= 0 {
|
|
1784
|
+
return nil // Within limits, proceed
|
|
1785
|
+
}
|
|
1786
|
+
|
|
1787
|
+
// Wait until we're within rate limits
|
|
1788
|
+
s.logger.Infof("Rate limit reached, waiting %v before sending...", waitDuration)
|
|
1789
|
+
time.Sleep(waitDuration)
|
|
1790
|
+
}
|
|
1791
|
+
}
|
|
1792
|
+
|
|
1793
|
+
// checkRateLimitInternal checks limits and returns wait duration or hard error
|
|
1794
|
+
func (s *Service) checkRateLimitInternal(recipient string) (time.Duration, error) {
|
|
1795
|
+
s.rateMu.Lock()
|
|
1796
|
+
defer s.rateMu.Unlock()
|
|
1797
|
+
|
|
1798
|
+
// Reset daily counters if needed
|
|
1799
|
+
s.resetDailyCountersIfNeeded()
|
|
1800
|
+
|
|
1801
|
+
// Check if paused - this is a hard error, can't wait
|
|
1802
|
+
if s.rateLimitStats.IsPaused {
|
|
1803
|
+
return 0, fmt.Errorf("rate limiting paused: %s", s.rateLimitStats.PauseReason)
|
|
1804
|
+
}
|
|
1805
|
+
|
|
1806
|
+
now := time.Now()
|
|
1807
|
+
|
|
1808
|
+
// Clean up old message times (keep only last hour)
|
|
1809
|
+
hourAgo := now.Add(-1 * time.Hour)
|
|
1810
|
+
newTimes := make([]time.Time, 0, len(s.messageTimes))
|
|
1811
|
+
for _, t := range s.messageTimes {
|
|
1812
|
+
if t.After(hourAgo) {
|
|
1813
|
+
newTimes = append(newTimes, t)
|
|
1814
|
+
}
|
|
1815
|
+
}
|
|
1816
|
+
s.messageTimes = newTimes
|
|
1817
|
+
|
|
1818
|
+
// Count messages in last minute and hour
|
|
1819
|
+
minuteAgo := now.Add(-1 * time.Minute)
|
|
1820
|
+
minuteCount := 0
|
|
1821
|
+
hourCount := len(s.messageTimes)
|
|
1822
|
+
|
|
1823
|
+
// Find oldest message in last minute (to calculate wait time)
|
|
1824
|
+
var oldestInMinute time.Time
|
|
1825
|
+
var oldestInHour time.Time
|
|
1826
|
+
for _, t := range s.messageTimes {
|
|
1827
|
+
if t.After(minuteAgo) {
|
|
1828
|
+
minuteCount++
|
|
1829
|
+
if oldestInMinute.IsZero() || t.Before(oldestInMinute) {
|
|
1830
|
+
oldestInMinute = t
|
|
1831
|
+
}
|
|
1832
|
+
}
|
|
1833
|
+
if oldestInHour.IsZero() || t.Before(oldestInHour) {
|
|
1834
|
+
oldestInHour = t
|
|
1835
|
+
}
|
|
1836
|
+
}
|
|
1837
|
+
|
|
1838
|
+
// Check per-minute limit - calculate wait time if exceeded
|
|
1839
|
+
if s.rateLimitConfig.MaxMessagesPerMinute > 0 && minuteCount >= s.rateLimitConfig.MaxMessagesPerMinute {
|
|
1840
|
+
// Wait until oldest message in minute window expires
|
|
1841
|
+
waitUntil := oldestInMinute.Add(1 * time.Minute)
|
|
1842
|
+
waitDuration := waitUntil.Sub(now) + 100*time.Millisecond // Add small buffer
|
|
1843
|
+
if waitDuration > 0 {
|
|
1844
|
+
s.logger.Debugf("Per-minute limit reached (%d/%d), need to wait %v",
|
|
1845
|
+
minuteCount, s.rateLimitConfig.MaxMessagesPerMinute, waitDuration)
|
|
1846
|
+
return waitDuration, nil
|
|
1847
|
+
}
|
|
1848
|
+
}
|
|
1849
|
+
|
|
1850
|
+
// Check per-hour limit - calculate wait time if exceeded
|
|
1851
|
+
if s.rateLimitConfig.MaxMessagesPerHour > 0 && hourCount >= s.rateLimitConfig.MaxMessagesPerHour {
|
|
1852
|
+
// Wait until oldest message in hour window expires
|
|
1853
|
+
waitUntil := oldestInHour.Add(1 * time.Hour)
|
|
1854
|
+
waitDuration := waitUntil.Sub(now) + 100*time.Millisecond // Add small buffer
|
|
1855
|
+
if waitDuration > 0 {
|
|
1856
|
+
s.logger.Debugf("Per-hour limit reached (%d/%d), need to wait %v",
|
|
1857
|
+
hourCount, s.rateLimitConfig.MaxMessagesPerHour, waitDuration)
|
|
1858
|
+
return waitDuration, nil
|
|
1859
|
+
}
|
|
1860
|
+
}
|
|
1861
|
+
|
|
1862
|
+
// Check new contacts limit - this is a HARD limit, return error (can't wait for tomorrow)
|
|
1863
|
+
if s.rateLimitConfig.MaxNewContactsPerDay > 0 {
|
|
1864
|
+
if _, seen := s.contactsSeen[recipient]; !seen {
|
|
1865
|
+
newContactsCount := 0
|
|
1866
|
+
for _, t := range s.newContactsToday {
|
|
1867
|
+
if t.After(s.dailyResetTime) {
|
|
1868
|
+
newContactsCount++
|
|
1869
|
+
}
|
|
1870
|
+
}
|
|
1871
|
+
if newContactsCount >= s.rateLimitConfig.MaxNewContactsPerDay {
|
|
1872
|
+
return 0, fmt.Errorf("new contacts limit exceeded: %d new contacts/day (max: %d) - try again tomorrow",
|
|
1873
|
+
newContactsCount, s.rateLimitConfig.MaxNewContactsPerDay)
|
|
1874
|
+
}
|
|
1875
|
+
}
|
|
1876
|
+
}
|
|
1877
|
+
|
|
1878
|
+
// Check response rate threshold (if enabled) - hard error
|
|
1879
|
+
if s.rateLimitConfig.PauseOnLowResponse && s.rateLimitStats.MessagesSentToday >= 10 {
|
|
1880
|
+
responseRate := float64(s.rateLimitStats.ResponsesReceived) / float64(s.rateLimitStats.MessagesSentToday)
|
|
1881
|
+
if responseRate < s.rateLimitConfig.ResponseRateThreshold {
|
|
1882
|
+
s.rateLimitStats.IsPaused = true
|
|
1883
|
+
s.rateLimitStats.PauseReason = fmt.Sprintf("low response rate: %.1f%% (threshold: %.1f%%)",
|
|
1884
|
+
responseRate*100, s.rateLimitConfig.ResponseRateThreshold*100)
|
|
1885
|
+
return 0, fmt.Errorf("rate limiting paused: %s", s.rateLimitStats.PauseReason)
|
|
1886
|
+
}
|
|
1887
|
+
}
|
|
1888
|
+
|
|
1889
|
+
return 0, nil // Within all limits
|
|
1890
|
+
}
|
|
1891
|
+
|
|
1892
|
+
// ApplyMessageDelay applies the configured delay before sending a message
|
|
1893
|
+
func (s *Service) ApplyMessageDelay(hasLinks bool) {
|
|
1894
|
+
s.rateMu.Lock()
|
|
1895
|
+
config := s.rateLimitConfig
|
|
1896
|
+
s.rateMu.Unlock()
|
|
1897
|
+
|
|
1898
|
+
if !config.Enabled || config.MinDelayMs <= 0 {
|
|
1899
|
+
return
|
|
1900
|
+
}
|
|
1901
|
+
|
|
1902
|
+
delay := config.MinDelayMs
|
|
1903
|
+
|
|
1904
|
+
// Add randomization if enabled
|
|
1905
|
+
if config.RandomizeDelays && config.MaxDelayMs > config.MinDelayMs {
|
|
1906
|
+
delay = config.MinDelayMs + rand.Intn(config.MaxDelayMs-config.MinDelayMs)
|
|
1907
|
+
}
|
|
1908
|
+
|
|
1909
|
+
// Add extra delay for messages with links
|
|
1910
|
+
if hasLinks && config.LinkExtraDelayMs > 0 {
|
|
1911
|
+
delay += config.LinkExtraDelayMs
|
|
1912
|
+
}
|
|
1913
|
+
|
|
1914
|
+
s.logger.Debugf("Applying message delay: %dms (links: %v)", delay, hasLinks)
|
|
1915
|
+
time.Sleep(time.Duration(delay) * time.Millisecond)
|
|
1916
|
+
}
|
|
1917
|
+
|
|
1918
|
+
// SendTypingIfEnabled sends typing indicator if configured
|
|
1919
|
+
// The typing indicator stays active until the message is sent
|
|
1920
|
+
func (s *Service) SendTypingIfEnabled(jidStr string) {
|
|
1921
|
+
s.rateMu.Lock()
|
|
1922
|
+
config := s.rateLimitConfig
|
|
1923
|
+
s.rateMu.Unlock()
|
|
1924
|
+
|
|
1925
|
+
if !config.Enabled || !config.SimulateTyping || config.TypingDelayMs <= 0 {
|
|
1926
|
+
return
|
|
1927
|
+
}
|
|
1928
|
+
|
|
1929
|
+
// Parse JID
|
|
1930
|
+
jid, err := types.ParseJID(jidStr)
|
|
1931
|
+
if err != nil {
|
|
1932
|
+
s.logger.Debugf("Could not parse JID for typing indicator: %v", err)
|
|
1933
|
+
return
|
|
1934
|
+
}
|
|
1935
|
+
|
|
1936
|
+
// Send composing indicator - stays active until message is sent
|
|
1937
|
+
ctx := context.Background()
|
|
1938
|
+
err = s.client.SendChatPresence(ctx, jid, types.ChatPresenceComposing, types.ChatPresenceMediaText)
|
|
1939
|
+
if err != nil {
|
|
1940
|
+
s.logger.Debugf("Failed to send typing indicator: %v", err)
|
|
1941
|
+
return
|
|
1942
|
+
}
|
|
1943
|
+
|
|
1944
|
+
s.logger.Debugf("Sent typing indicator to %s, waiting %dms", jidStr, config.TypingDelayMs)
|
|
1945
|
+
|
|
1946
|
+
// Wait for typing duration (indicator stays active)
|
|
1947
|
+
time.Sleep(time.Duration(config.TypingDelayMs) * time.Millisecond)
|
|
1948
|
+
|
|
1949
|
+
// No paused indicator - typing stays active until message is sent
|
|
1950
|
+
// WhatsApp clears the typing indicator when the message arrives
|
|
1951
|
+
}
|
|
1952
|
+
|
|
1953
|
+
// RecordMessageSent updates rate limiting stats after a message is sent
|
|
1954
|
+
func (s *Service) RecordMessageSent(recipient string) {
|
|
1955
|
+
s.rateMu.Lock()
|
|
1956
|
+
defer s.rateMu.Unlock()
|
|
1957
|
+
|
|
1958
|
+
now := time.Now()
|
|
1959
|
+
|
|
1960
|
+
// Record message time
|
|
1961
|
+
s.messageTimes = append(s.messageTimes, now)
|
|
1962
|
+
s.rateLimitStats.MessagesSentToday++
|
|
1963
|
+
s.rateLimitStats.LastMessageTime = now
|
|
1964
|
+
|
|
1965
|
+
// Track new contacts
|
|
1966
|
+
if _, seen := s.contactsSeen[recipient]; !seen {
|
|
1967
|
+
s.contactsSeen[recipient] = true
|
|
1968
|
+
s.newContactsToday[recipient] = now
|
|
1969
|
+
s.logger.Debugf("New contact recorded: %s (total today: %d)", recipient, len(s.newContactsToday))
|
|
1970
|
+
}
|
|
1971
|
+
}
|
|
1972
|
+
|
|
1973
|
+
// RecordResponseReceived increments the response counter (call when receiving a message)
|
|
1974
|
+
func (s *Service) RecordResponseReceived() {
|
|
1975
|
+
s.rateMu.Lock()
|
|
1976
|
+
defer s.rateMu.Unlock()
|
|
1977
|
+
s.rateLimitStats.ResponsesReceived++
|
|
1978
|
+
}
|
|
1979
|
+
|
|
1980
|
+
// UnpauseRateLimiting resumes rate limiting after it was paused
|
|
1981
|
+
func (s *Service) UnpauseRateLimiting() {
|
|
1982
|
+
s.rateMu.Lock()
|
|
1983
|
+
defer s.rateMu.Unlock()
|
|
1984
|
+
s.rateLimitStats.IsPaused = false
|
|
1985
|
+
s.rateLimitStats.PauseReason = ""
|
|
1986
|
+
s.logger.Info("Rate limiting unpaused")
|
|
1987
|
+
}
|
|
1988
|
+
|
|
1989
|
+
func (s *Service) Shutdown() {
|
|
1990
|
+
if s.shutdown {
|
|
1991
|
+
return // Already shutdown
|
|
1992
|
+
}
|
|
1993
|
+
|
|
1994
|
+
s.logger.Info("Shutting down WhatsApp service...")
|
|
1995
|
+
s.shutdown = true
|
|
1996
|
+
s.running = false
|
|
1997
|
+
s.pairing = false
|
|
1998
|
+
|
|
1999
|
+
if s.client != nil {
|
|
2000
|
+
s.client.Disconnect()
|
|
2001
|
+
}
|
|
2002
|
+
|
|
2003
|
+
// Give goroutines more time to exit cleanly
|
|
2004
|
+
time.Sleep(500 * time.Millisecond)
|
|
2005
|
+
|
|
2006
|
+
// Drain any remaining events but DON'T close the channel
|
|
2007
|
+
// This allows ForwardEvents goroutines to continue working after restart
|
|
2008
|
+
for {
|
|
2009
|
+
select {
|
|
2010
|
+
case <-s.events:
|
|
2011
|
+
// Keep draining
|
|
2012
|
+
default:
|
|
2013
|
+
goto done
|
|
2014
|
+
}
|
|
2015
|
+
}
|
|
2016
|
+
done:
|
|
2017
|
+
|
|
2018
|
+
// DO NOT close(s.events) - keep channel open for restart
|
|
2019
|
+
s.logger.Info("WhatsApp service shutdown complete")
|
|
2020
|
+
}
|
|
2021
|
+
|
|
2022
|
+
func (s *Service) Reset() error {
|
|
2023
|
+
s.logger.Info("Starting full reset with logout...")
|
|
2024
|
+
|
|
2025
|
+
// Logout from WhatsApp servers first (invalidates session on server side)
|
|
2026
|
+
if s.client != nil && s.client.IsLoggedIn() {
|
|
2027
|
+
s.logger.Info("Logging out from WhatsApp...")
|
|
2028
|
+
if err := s.client.Logout(context.Background()); err != nil {
|
|
2029
|
+
s.logger.Warnf("Logout failed (may already be logged out): %v", err)
|
|
2030
|
+
} else {
|
|
2031
|
+
s.logger.Info("Logged out from WhatsApp successfully")
|
|
2032
|
+
}
|
|
2033
|
+
}
|
|
2034
|
+
|
|
2035
|
+
// Shutdown the service (disconnect, close channels)
|
|
2036
|
+
s.Shutdown()
|
|
2037
|
+
|
|
2038
|
+
// Delete the device session from store
|
|
2039
|
+
if s.client != nil && s.client.Store != nil {
|
|
2040
|
+
s.logger.Info("Deleting device session from store...")
|
|
2041
|
+
if err := s.client.Store.Delete(context.Background()); err != nil {
|
|
2042
|
+
s.logger.Warnf("Failed to delete device store: %v", err)
|
|
2043
|
+
}
|
|
2044
|
+
}
|
|
2045
|
+
|
|
2046
|
+
// Clear message history (belongs to old instance)
|
|
2047
|
+
if s.historyStore != nil {
|
|
2048
|
+
s.logger.Info("Clearing message history...")
|
|
2049
|
+
if err := s.historyStore.ClearHistory(); err != nil {
|
|
2050
|
+
s.logger.Warnf("Failed to clear history: %v", err)
|
|
2051
|
+
}
|
|
2052
|
+
}
|
|
2053
|
+
|
|
2054
|
+
// Clear client reference to allow GC and release DB locks
|
|
2055
|
+
s.client = nil
|
|
2056
|
+
s.container = nil
|
|
2057
|
+
|
|
2058
|
+
// Give time for database to release locks
|
|
2059
|
+
time.Sleep(500 * time.Millisecond)
|
|
2060
|
+
|
|
2061
|
+
// Reinitialize client with fresh database
|
|
2062
|
+
if err := s.reinitClient(); err != nil {
|
|
2063
|
+
s.logger.Errorf("Failed to reinitialize client: %v", err)
|
|
2064
|
+
return fmt.Errorf("failed to reinitialize client: %w", err)
|
|
2065
|
+
}
|
|
2066
|
+
|
|
2067
|
+
s.logger.Info("WhatsApp reset complete")
|
|
2068
|
+
return nil
|
|
2069
|
+
}
|
|
2070
|
+
|
|
2071
|
+
// reinitClient creates a new database and client after reset
|
|
2072
|
+
func (s *Service) reinitClient() error {
|
|
2073
|
+
s.logger.Info("Reinitializing WhatsApp client...")
|
|
2074
|
+
|
|
2075
|
+
// Create new database
|
|
2076
|
+
dbLog := waLog.Stdout("Database", "INFO", true)
|
|
2077
|
+
ctx := context.Background()
|
|
2078
|
+
container, err := sqlstore.New(ctx, "sqlite", "file:"+s.dbPath+"?_pragma=foreign_keys(1)&_journal_mode=WAL&_busy_timeout=30000&cache=shared", dbLog)
|
|
2079
|
+
if err != nil {
|
|
2080
|
+
return fmt.Errorf("failed to initialize database: %w", err)
|
|
2081
|
+
}
|
|
2082
|
+
|
|
2083
|
+
// Get device store
|
|
2084
|
+
deviceStore, err := container.GetFirstDevice(ctx)
|
|
2085
|
+
if err != nil {
|
|
2086
|
+
return fmt.Errorf("failed to get device: %w", err)
|
|
2087
|
+
}
|
|
2088
|
+
|
|
2089
|
+
// Create new client
|
|
2090
|
+
clientLog := waLog.Stdout("WhatsApp", "INFO", true)
|
|
2091
|
+
client := whatsmeow.NewClient(deviceStore, clientLog)
|
|
2092
|
+
|
|
2093
|
+
// Update service references
|
|
2094
|
+
s.container = container
|
|
2095
|
+
s.client = client
|
|
2096
|
+
|
|
2097
|
+
// Add event handler to new client
|
|
2098
|
+
client.AddEventHandler(s.eventHandler)
|
|
2099
|
+
|
|
2100
|
+
s.logger.Info("WhatsApp client reinitialized successfully")
|
|
2101
|
+
return nil
|
|
2102
|
+
}
|
|
2103
|
+
|
|
2104
|
+
func (s *Service) cleanupOldQRCodes() {
|
|
2105
|
+
s.CleanupQRCodes()
|
|
2106
|
+
}
|
|
2107
|
+
|
|
2108
|
+
// CleanupQRCodes deletes all QR code PNG files
|
|
2109
|
+
func (s *Service) CleanupQRCodes() {
|
|
2110
|
+
// Delete all old QR code PNG files
|
|
2111
|
+
files, err := filepath.Glob("data/qr/qr_*.png")
|
|
2112
|
+
if err != nil {
|
|
2113
|
+
s.logger.Warnf("Failed to find old QR code files: %v", err)
|
|
2114
|
+
return
|
|
2115
|
+
}
|
|
2116
|
+
|
|
2117
|
+
if len(files) > 0 {
|
|
2118
|
+
s.logger.Infof("Cleaning up %d old QR code file(s)", len(files))
|
|
2119
|
+
for _, file := range files {
|
|
2120
|
+
if err := os.Remove(file); err != nil {
|
|
2121
|
+
s.logger.Warnf("Failed to delete old QR code file %s: %v", file, err)
|
|
2122
|
+
} else {
|
|
2123
|
+
s.logger.Debugf("Deleted old QR code file: %s", file)
|
|
2124
|
+
}
|
|
2125
|
+
}
|
|
2126
|
+
}
|
|
2127
|
+
|
|
2128
|
+
// Clear cached QR code
|
|
2129
|
+
s.lastQRCode = nil
|
|
2130
|
+
}
|