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.
Files changed (288) hide show
  1. package/.env.template +71 -0
  2. package/LICENSE +21 -0
  3. package/README.md +87 -0
  4. package/bin/cli.js +159 -0
  5. package/client/.dockerignore +45 -0
  6. package/client/Dockerfile +68 -0
  7. package/client/eslint.config.js +29 -0
  8. package/client/index.html +13 -0
  9. package/client/nginx.conf +66 -0
  10. package/client/package.json +48 -0
  11. package/client/src/App.tsx +27 -0
  12. package/client/src/Dashboard.tsx +1173 -0
  13. package/client/src/ParameterPanel.tsx +301 -0
  14. package/client/src/components/AIAgentNode.tsx +321 -0
  15. package/client/src/components/APIKeyValidator.tsx +118 -0
  16. package/client/src/components/ClaudeChatModelNode.tsx +18 -0
  17. package/client/src/components/ConditionalEdge.tsx +189 -0
  18. package/client/src/components/CredentialsModal.tsx +306 -0
  19. package/client/src/components/EdgeConditionEditor.tsx +443 -0
  20. package/client/src/components/GeminiChatModelNode.tsx +18 -0
  21. package/client/src/components/GenericNode.tsx +357 -0
  22. package/client/src/components/LocationParameterPanel.tsx +154 -0
  23. package/client/src/components/ModelNode.tsx +286 -0
  24. package/client/src/components/OpenAIChatModelNode.tsx +18 -0
  25. package/client/src/components/OutputPanel.tsx +471 -0
  26. package/client/src/components/ParameterRenderer.tsx +1874 -0
  27. package/client/src/components/SkillEditorModal.tsx +417 -0
  28. package/client/src/components/SquareNode.tsx +797 -0
  29. package/client/src/components/StartNode.tsx +250 -0
  30. package/client/src/components/ToolkitNode.tsx +365 -0
  31. package/client/src/components/TriggerNode.tsx +463 -0
  32. package/client/src/components/auth/LoginPage.tsx +247 -0
  33. package/client/src/components/auth/ProtectedRoute.tsx +59 -0
  34. package/client/src/components/base/BaseChatModelNode.tsx +271 -0
  35. package/client/src/components/icons/AIProviderIcons.tsx +50 -0
  36. package/client/src/components/maps/GoogleMapsPicker.tsx +137 -0
  37. package/client/src/components/maps/MapsPreviewPanel.tsx +110 -0
  38. package/client/src/components/maps/index.ts +26 -0
  39. package/client/src/components/parameterPanel/InputSection.tsx +1094 -0
  40. package/client/src/components/parameterPanel/LocationPanelLayout.tsx +65 -0
  41. package/client/src/components/parameterPanel/MapsSection.tsx +92 -0
  42. package/client/src/components/parameterPanel/MiddleSection.tsx +571 -0
  43. package/client/src/components/parameterPanel/OutputSection.tsx +81 -0
  44. package/client/src/components/parameterPanel/ParameterPanelLayout.tsx +82 -0
  45. package/client/src/components/parameterPanel/ToolSchemaEditor.tsx +436 -0
  46. package/client/src/components/parameterPanel/index.ts +42 -0
  47. package/client/src/components/shared/DataPanel.tsx +142 -0
  48. package/client/src/components/shared/JSONTreeRenderer.tsx +106 -0
  49. package/client/src/components/ui/AIResultModal.tsx +204 -0
  50. package/client/src/components/ui/AndroidSettingsPanel.tsx +401 -0
  51. package/client/src/components/ui/CodeEditor.tsx +81 -0
  52. package/client/src/components/ui/CollapsibleSection.tsx +88 -0
  53. package/client/src/components/ui/ComponentItem.tsx +154 -0
  54. package/client/src/components/ui/ComponentPalette.tsx +321 -0
  55. package/client/src/components/ui/ConsolePanel.tsx +1074 -0
  56. package/client/src/components/ui/ErrorBoundary.tsx +196 -0
  57. package/client/src/components/ui/InputNodesPanel.tsx +204 -0
  58. package/client/src/components/ui/MapSelector.tsx +314 -0
  59. package/client/src/components/ui/Modal.tsx +149 -0
  60. package/client/src/components/ui/NodeContextMenu.tsx +192 -0
  61. package/client/src/components/ui/NodeOutputPanel.tsx +1150 -0
  62. package/client/src/components/ui/OutputDisplayPanel.tsx +381 -0
  63. package/client/src/components/ui/SettingsPanel.tsx +243 -0
  64. package/client/src/components/ui/TopToolbar.tsx +736 -0
  65. package/client/src/components/ui/WhatsAppSettingsPanel.tsx +345 -0
  66. package/client/src/components/ui/WorkflowSidebar.tsx +294 -0
  67. package/client/src/config/antdTheme.ts +186 -0
  68. package/client/src/config/api.ts +54 -0
  69. package/client/src/contexts/AuthContext.tsx +221 -0
  70. package/client/src/contexts/ThemeContext.tsx +42 -0
  71. package/client/src/contexts/WebSocketContext.tsx +1971 -0
  72. package/client/src/factories/baseChatModelFactory.ts +256 -0
  73. package/client/src/hooks/useAndroidOperations.ts +164 -0
  74. package/client/src/hooks/useApiKeyValidation.ts +107 -0
  75. package/client/src/hooks/useApiKeys.ts +238 -0
  76. package/client/src/hooks/useAppTheme.ts +17 -0
  77. package/client/src/hooks/useComponentPalette.ts +51 -0
  78. package/client/src/hooks/useCopyPaste.ts +155 -0
  79. package/client/src/hooks/useDragAndDrop.ts +124 -0
  80. package/client/src/hooks/useDragVariable.ts +88 -0
  81. package/client/src/hooks/useExecution.ts +313 -0
  82. package/client/src/hooks/useParameterPanel.ts +176 -0
  83. package/client/src/hooks/useReactFlowNodes.ts +189 -0
  84. package/client/src/hooks/useToolSchema.ts +209 -0
  85. package/client/src/hooks/useWhatsApp.ts +196 -0
  86. package/client/src/hooks/useWorkflowManagement.ts +46 -0
  87. package/client/src/index.css +315 -0
  88. package/client/src/main.tsx +19 -0
  89. package/client/src/nodeDefinitions/aiAgentNodes.ts +336 -0
  90. package/client/src/nodeDefinitions/aiModelNodes.ts +340 -0
  91. package/client/src/nodeDefinitions/androidDeviceNodes.ts +140 -0
  92. package/client/src/nodeDefinitions/androidServiceNodes.ts +383 -0
  93. package/client/src/nodeDefinitions/chatNodes.ts +135 -0
  94. package/client/src/nodeDefinitions/codeNodes.ts +54 -0
  95. package/client/src/nodeDefinitions/documentNodes.ts +379 -0
  96. package/client/src/nodeDefinitions/index.ts +15 -0
  97. package/client/src/nodeDefinitions/locationNodes.ts +463 -0
  98. package/client/src/nodeDefinitions/schedulerNodes.ts +220 -0
  99. package/client/src/nodeDefinitions/skillNodes.ts +211 -0
  100. package/client/src/nodeDefinitions/toolNodes.ts +198 -0
  101. package/client/src/nodeDefinitions/utilityNodes.ts +284 -0
  102. package/client/src/nodeDefinitions/whatsappNodes.ts +865 -0
  103. package/client/src/nodeDefinitions/workflowNodes.ts +41 -0
  104. package/client/src/nodeDefinitions.ts +104 -0
  105. package/client/src/schemas/workflowSchema.ts +264 -0
  106. package/client/src/services/dynamicParameterService.ts +96 -0
  107. package/client/src/services/execution/aiAgentExecutionService.ts +35 -0
  108. package/client/src/services/executionService.ts +232 -0
  109. package/client/src/services/workflowApi.ts +91 -0
  110. package/client/src/store/useAppStore.ts +582 -0
  111. package/client/src/styles/theme.ts +508 -0
  112. package/client/src/styles/zIndex.ts +17 -0
  113. package/client/src/types/ComponentTypes.ts +39 -0
  114. package/client/src/types/EdgeCondition.ts +231 -0
  115. package/client/src/types/INodeProperties.ts +288 -0
  116. package/client/src/types/NodeTypes.ts +28 -0
  117. package/client/src/utils/formatters.ts +33 -0
  118. package/client/src/utils/googleMapsLoader.ts +140 -0
  119. package/client/src/utils/locationUtils.ts +85 -0
  120. package/client/src/utils/nodeUtils.ts +31 -0
  121. package/client/src/utils/workflow.ts +30 -0
  122. package/client/src/utils/workflowExport.ts +120 -0
  123. package/client/src/vite-env.d.ts +12 -0
  124. package/client/tailwind.config.js +60 -0
  125. package/client/tsconfig.json +25 -0
  126. package/client/tsconfig.node.json +11 -0
  127. package/client/vite.config.js +35 -0
  128. package/docker-compose.prod.yml +107 -0
  129. package/docker-compose.yml +104 -0
  130. package/docs-MachinaOs/README.md +85 -0
  131. package/docs-MachinaOs/deployment/docker.mdx +228 -0
  132. package/docs-MachinaOs/deployment/production.mdx +345 -0
  133. package/docs-MachinaOs/docs.json +75 -0
  134. package/docs-MachinaOs/faq.mdx +309 -0
  135. package/docs-MachinaOs/favicon.svg +5 -0
  136. package/docs-MachinaOs/installation.mdx +160 -0
  137. package/docs-MachinaOs/introduction.mdx +114 -0
  138. package/docs-MachinaOs/logo/dark.svg +6 -0
  139. package/docs-MachinaOs/logo/light.svg +6 -0
  140. package/docs-MachinaOs/nodes/ai-agent.mdx +216 -0
  141. package/docs-MachinaOs/nodes/ai-models.mdx +240 -0
  142. package/docs-MachinaOs/nodes/android.mdx +411 -0
  143. package/docs-MachinaOs/nodes/overview.mdx +181 -0
  144. package/docs-MachinaOs/nodes/schedulers.mdx +316 -0
  145. package/docs-MachinaOs/nodes/webhooks.mdx +330 -0
  146. package/docs-MachinaOs/nodes/whatsapp.mdx +305 -0
  147. package/docs-MachinaOs/quickstart.mdx +119 -0
  148. package/docs-MachinaOs/tutorials/ai-agent-workflow.mdx +177 -0
  149. package/docs-MachinaOs/tutorials/android-automation.mdx +242 -0
  150. package/docs-MachinaOs/tutorials/first-workflow.mdx +134 -0
  151. package/docs-MachinaOs/tutorials/whatsapp-automation.mdx +185 -0
  152. package/nul +0 -0
  153. package/package.json +70 -0
  154. package/scripts/build.js +158 -0
  155. package/scripts/check-ports.ps1 +33 -0
  156. package/scripts/clean.js +40 -0
  157. package/scripts/docker.js +93 -0
  158. package/scripts/kill-port.ps1 +154 -0
  159. package/scripts/start.js +210 -0
  160. package/scripts/stop.js +325 -0
  161. package/server/.dockerignore +44 -0
  162. package/server/Dockerfile +45 -0
  163. package/server/constants.py +249 -0
  164. package/server/core/__init__.py +1 -0
  165. package/server/core/cache.py +461 -0
  166. package/server/core/config.py +128 -0
  167. package/server/core/container.py +99 -0
  168. package/server/core/database.py +1211 -0
  169. package/server/core/logging.py +314 -0
  170. package/server/main.py +289 -0
  171. package/server/middleware/__init__.py +5 -0
  172. package/server/middleware/auth.py +89 -0
  173. package/server/models/__init__.py +1 -0
  174. package/server/models/auth.py +52 -0
  175. package/server/models/cache.py +24 -0
  176. package/server/models/database.py +211 -0
  177. package/server/models/nodes.py +455 -0
  178. package/server/package.json +9 -0
  179. package/server/pyproject.toml +72 -0
  180. package/server/requirements.txt +83 -0
  181. package/server/routers/__init__.py +1 -0
  182. package/server/routers/android.py +294 -0
  183. package/server/routers/auth.py +203 -0
  184. package/server/routers/database.py +151 -0
  185. package/server/routers/maps.py +142 -0
  186. package/server/routers/nodejs_compat.py +289 -0
  187. package/server/routers/webhook.py +90 -0
  188. package/server/routers/websocket.py +2127 -0
  189. package/server/routers/whatsapp.py +761 -0
  190. package/server/routers/workflow.py +200 -0
  191. package/server/services/__init__.py +1 -0
  192. package/server/services/ai.py +2415 -0
  193. package/server/services/android/__init__.py +27 -0
  194. package/server/services/android/broadcaster.py +114 -0
  195. package/server/services/android/client.py +608 -0
  196. package/server/services/android/manager.py +78 -0
  197. package/server/services/android/protocol.py +165 -0
  198. package/server/services/android_service.py +588 -0
  199. package/server/services/auth.py +131 -0
  200. package/server/services/chat_client.py +160 -0
  201. package/server/services/deployment/__init__.py +12 -0
  202. package/server/services/deployment/manager.py +706 -0
  203. package/server/services/deployment/state.py +47 -0
  204. package/server/services/deployment/triggers.py +275 -0
  205. package/server/services/event_waiter.py +785 -0
  206. package/server/services/execution/__init__.py +77 -0
  207. package/server/services/execution/cache.py +769 -0
  208. package/server/services/execution/conditions.py +373 -0
  209. package/server/services/execution/dlq.py +132 -0
  210. package/server/services/execution/executor.py +1351 -0
  211. package/server/services/execution/models.py +531 -0
  212. package/server/services/execution/recovery.py +235 -0
  213. package/server/services/handlers/__init__.py +126 -0
  214. package/server/services/handlers/ai.py +355 -0
  215. package/server/services/handlers/android.py +260 -0
  216. package/server/services/handlers/code.py +278 -0
  217. package/server/services/handlers/document.py +598 -0
  218. package/server/services/handlers/http.py +193 -0
  219. package/server/services/handlers/polyglot.py +105 -0
  220. package/server/services/handlers/tools.py +845 -0
  221. package/server/services/handlers/triggers.py +107 -0
  222. package/server/services/handlers/utility.py +822 -0
  223. package/server/services/handlers/whatsapp.py +476 -0
  224. package/server/services/maps.py +289 -0
  225. package/server/services/memory_store.py +103 -0
  226. package/server/services/node_executor.py +375 -0
  227. package/server/services/parameter_resolver.py +218 -0
  228. package/server/services/polyglot_client.py +169 -0
  229. package/server/services/scheduler.py +155 -0
  230. package/server/services/skill_loader.py +417 -0
  231. package/server/services/status_broadcaster.py +826 -0
  232. package/server/services/temporal/__init__.py +23 -0
  233. package/server/services/temporal/activities.py +344 -0
  234. package/server/services/temporal/client.py +76 -0
  235. package/server/services/temporal/executor.py +147 -0
  236. package/server/services/temporal/worker.py +251 -0
  237. package/server/services/temporal/workflow.py +355 -0
  238. package/server/services/temporal/ws_client.py +236 -0
  239. package/server/services/text.py +111 -0
  240. package/server/services/user_auth.py +172 -0
  241. package/server/services/websocket_client.py +29 -0
  242. package/server/services/workflow.py +597 -0
  243. package/server/skills/android-skill/SKILL.md +82 -0
  244. package/server/skills/assistant-personality/SKILL.md +45 -0
  245. package/server/skills/code-skill/SKILL.md +140 -0
  246. package/server/skills/http-skill/SKILL.md +161 -0
  247. package/server/skills/maps-skill/SKILL.md +170 -0
  248. package/server/skills/memory-skill/SKILL.md +154 -0
  249. package/server/skills/scheduler-skill/SKILL.md +84 -0
  250. package/server/skills/whatsapp-skill/SKILL.md +283 -0
  251. package/server/uv.lock +2916 -0
  252. package/server/whatsapp-rpc/.dockerignore +30 -0
  253. package/server/whatsapp-rpc/Dockerfile +44 -0
  254. package/server/whatsapp-rpc/Dockerfile.web +17 -0
  255. package/server/whatsapp-rpc/README.md +139 -0
  256. package/server/whatsapp-rpc/cli.js +95 -0
  257. package/server/whatsapp-rpc/configs/config.yaml +7 -0
  258. package/server/whatsapp-rpc/docker-compose.yml +35 -0
  259. package/server/whatsapp-rpc/docs/API.md +410 -0
  260. package/server/whatsapp-rpc/go.mod +67 -0
  261. package/server/whatsapp-rpc/go.sum +203 -0
  262. package/server/whatsapp-rpc/package.json +30 -0
  263. package/server/whatsapp-rpc/schema.json +1294 -0
  264. package/server/whatsapp-rpc/scripts/clean.cjs +66 -0
  265. package/server/whatsapp-rpc/scripts/cli.js +162 -0
  266. package/server/whatsapp-rpc/src/go/cmd/server/main.go +91 -0
  267. package/server/whatsapp-rpc/src/go/config/config.go +49 -0
  268. package/server/whatsapp-rpc/src/go/rpc/rpc.go +446 -0
  269. package/server/whatsapp-rpc/src/go/rpc/server.go +112 -0
  270. package/server/whatsapp-rpc/src/go/whatsapp/history.go +166 -0
  271. package/server/whatsapp-rpc/src/go/whatsapp/messages.go +390 -0
  272. package/server/whatsapp-rpc/src/go/whatsapp/service.go +2130 -0
  273. package/server/whatsapp-rpc/src/go/whatsapp/types.go +261 -0
  274. package/server/whatsapp-rpc/src/python/pyproject.toml +15 -0
  275. package/server/whatsapp-rpc/src/python/whatsapp_rpc/__init__.py +4 -0
  276. package/server/whatsapp-rpc/src/python/whatsapp_rpc/client.py +427 -0
  277. package/server/whatsapp-rpc/web/app.py +609 -0
  278. package/server/whatsapp-rpc/web/requirements.txt +6 -0
  279. package/server/whatsapp-rpc/web/rpc_client.py +427 -0
  280. package/server/whatsapp-rpc/web/static/openapi.yaml +59 -0
  281. package/server/whatsapp-rpc/web/templates/base.html +150 -0
  282. package/server/whatsapp-rpc/web/templates/contacts.html +240 -0
  283. package/server/whatsapp-rpc/web/templates/dashboard.html +320 -0
  284. package/server/whatsapp-rpc/web/templates/groups.html +328 -0
  285. package/server/whatsapp-rpc/web/templates/messages.html +465 -0
  286. package/server/whatsapp-rpc/web/templates/messaging.html +681 -0
  287. package/server/whatsapp-rpc/web/templates/send.html +259 -0
  288. 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
+ }