Source file Plugin_social.ml
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
open Prelude
open Containers
module J = Yojson.Safe.Util
type json = Yojson.Safe.t
type to_tell = {
from: string;
on_channel: string;
msg: string;
tell_after: float option; (** optional; not before this deadline (UTC) *)
}
type contact = {
last_seen: float;
to_tell: to_tell list;
ignore_user: bool;
}
exception Bad_json
let contact_of_json (json: json): contact option =
let member k =
match J.member k json with
| `Null -> raise Bad_json
| v -> v in
try
{ last_seen = member "lastSeen" |> J.to_float;
to_tell =
member "to_tell"
|> J.convert_each (fun j ->
match J.convert_each J.to_string j with
| [from; on_channel; msg] -> {from; on_channel; msg; tell_after=None}
| [from; on_channel; msg; tell_after] ->
let tell_after = Some (float_of_string tell_after) in
{from; on_channel; msg; tell_after;}
| _ -> raise Bad_json);
ignore_user = match J.member "ignore_user" json with
| `Null -> false;
| v -> J.to_bool_option v
|> CCOpt.get_or ~default:false
} |> some
with Bad_json | J.Type_error (_, _) -> None
let json_of_contact (c: contact): json =
`Assoc [
"lastSeen", `Float c.last_seen;
"to_tell", `List (
List.map (fun {from; on_channel; msg; tell_after} ->
let last = match tell_after with
| None -> []
| Some f -> [`String (string_of_float f)]
in
`List ([`String from; `String on_channel; `String msg] @ last)
) c.to_tell
);
"ignore_user", `Bool c.ignore_user
]
type state = {
actions: Plugin.action_callback;
mutable map: contact StrMap.t;
}
let write_db (db:state) =
Signal.Send_ref.send db.actions Plugin.Require_save
let is_contact state nick = StrMap.mem nick state.map
let set_data state ?(force_sync = true) nick contact =
state.map <- StrMap.add nick contact state.map;
if force_sync then Lwt.async (fun () -> write_db state)
let new_contact state nick =
if not (is_contact state nick) then
set_data state nick {
last_seen = Unix.time ();
to_tell = [];
ignore_user = false;
}
let data state nick =
if not @@ is_contact state nick then new_contact state nick;
StrMap.find nick state.map
let split_2 ~msg re s =
let a = Re.split re s in
match a with
| x :: y -> x, String.concat " " y
| _ -> raise (Command.Fail msg)
let split_3 ~msg re s =
let a = Re.split re s in
match a with
| x::y::tail -> x,y,String.concat " " tail
| _ -> raise (Command.Fail msg)
let cmd_tell_inner ~at state =
Command.make_simple
~descr:("ask the bot to transmit a message to someone absent\n"
^ if at then "format: <date> <nick> <msg>" else "format: <nick> <msg>")
~prio:10 ~cmd:(if at then "tell_at" else "tell")
(fun msg s ->
let nick = msg.Core.nick in
let target = Core.reply_to msg in
let s = String.trim s in
try
let dest, msg, tell_after =
if at
then (
let d, m, t =
split_3 ~msg:"tell_at: expected <date> <nick> <msg>"
(Re.Perl.compile_pat "[ \t]+") s
in
let t = ISO8601.Permissive.datetime ~reqtime:false t in
d, m, Some t
) else (
let d, m =
split_2 ~msg:"tell: expected <nick> <msg>"
(Re.Perl.compile_pat "[ \t]+") s
in
d, m, None
)
in
set_data state dest
{(data state dest) with
to_tell =
{from=nick; on_channel=target; msg; tell_after}
:: (data state dest).to_tell};
Lwt.return_some (Talk.select Talk.Ack)
with
| Command.Fail _ as e -> Lwt.fail e
| e -> Lwt.fail (Command.Fail ("tell: " ^ Printexc.to_string e))
)
let cmd_tell = cmd_tell_inner ~at:false
let cmd_tell_at = cmd_tell_inner ~at:true
let print_diff (f:float) : string =
let spf = Printf.sprintf in
let s = Pervasives.mod_float f 60. |> int_of_float in
let m = Pervasives.mod_float (f /. 60.) 60. |> int_of_float in
let h = Pervasives.mod_float (f /. 3600.) 24. |> int_of_float in
let days = f /. (3600. *. 24.) |> int_of_float in
[ (if days > 0 then [spf "%d days" days] else []);
(if h > 0 then [spf "%d hours" h] else []);
(if m > 0 then [spf "%d minutes" m] else []);
[spf "%d seconds" s];
] |> List.flatten |> String.concat ", "
let create_message_for_user now (user,last) =
let diff = now -. last in
CCFormat.sprintf "seen %s last: %s ago" user (print_diff diff)
let cmd_seen (state:state) =
Command.make_simple_l
~descr:"ask for the last time someone talked on this chan"
~prio:10 ~cmd:"seen"
(fun _msg s ->
try
let dest = CCString.trim s |> CCString.uppercase_ascii in
Logs.debug ~src:Core.logs_src (fun k->k "query: seen `%s`" dest);
let now = Unix.time () in
StrMap.fold (fun name data acc ->
if String.equal dest (CCString.uppercase_ascii name) then
(name, data.last_seen) :: acc
else
acc )
state.map []
|> CCList.sort (fun a b -> - (Float.compare (snd a) (snd b)) )
|> CCList.map (create_message_for_user now)
|> Lwt.return
with e ->
Lwt.fail (Command.Fail ("seen: " ^ Printexc.to_string e)))
let cmd_last (state:state) =
Command.make_simple_l
~descr:"ask for the last n people talking on this chan (default: n=3)"
~prio:10 ~cmd:"last"
(fun msg s ->
try
let default_n = 3 in
let dest = String.trim s in
Logs.debug ~src:Core.logs_src (fun k->k "query: last `%s`" dest);
let top_n = try match int_of_string dest with
| x when x > 0 -> x
| _ -> default_n
with
| Failure _ -> default_n
in
let now = Unix.time () in
let user_times =
StrMap.fold (fun key contact acc ->
if Pervasives.(<>) key msg.Core.nick && contact.ignore_user |> not then
(key, contact.last_seen) :: acc
else
acc
)
state.map []
|> CCList.sort (fun a b -> - (Float.compare (snd a) (snd b)) )
|> CCList.tl
|> CCList.take top_n
|> CCList.map (create_message_for_user now)
in
Lwt.return user_times
with e ->
Lwt.fail (Command.Fail ("last_seen: " ^ Printexc.to_string e)))
let cmd_ignore_template ~cmd prefix_stem ignore (state:state) =
Command.make_simple
~descr:(cmd ^ " nick") ~prio:10 ~cmd
(fun _ s ->
try
let dest = String.trim s in
Logs.debug ~src:Core.logs_src (fun k->k "query: ignore `%s`" dest);
if String.equal dest "" then (
Lwt.return None
) else (
let contact = data state dest in
let msg =
if Bool.equal contact.ignore_user ignore then
CCFormat.sprintf "already %sing %s" prefix_stem dest |> some
else (
set_data ~force_sync:true state dest
{ contact with ignore_user = ignore };
CCFormat.sprintf "%sing %s" prefix_stem dest |> some )
in
Lwt.return msg )
with e ->
Lwt.fail (Command.Fail (cmd ^ ": " ^ Printexc.to_string e)))
let cmd_ignore = cmd_ignore_template ~cmd:"ignore" "ignor" true
let cmd_unignore = cmd_ignore_template ~cmd:"unignore" "unignor" false
let cmd_ignore_list (state:state) =
Command.make_simple_l
~descr:"add nick to list of ignored people" ~prio:10 ~cmd:"ignore_list"
(fun _ _ ->
try
Logs.debug ~src:Core.logs_src (fun k->k "query: ignore_list");
let ignored =
StrMap.fold (fun name -> function
| { ignore_user = true; _ } -> fun x -> name :: x
| _ -> fun x -> x
) state.map [] in
let msg =
if CCList.is_empty ignored
then ["noone ignored!"]
else "ignoring:" :: ignored
in
Lwt.return msg
with e ->
Lwt.fail (Command.Fail ("ignore_list: " ^ Printexc.to_string e)))
let on_message state (module C:Core.S) msg =
let module Msg = Irc_message in
let nick = match msg.Msg.command with
| Msg.JOIN (_, _) | Msg.PRIVMSG (_, _) ->
some @@ get_nick @@ Option.get_exn msg.Msg.prefix
| Msg.NICK newnick ->
Some newnick
| _ -> None
in
begin match nick with
| None -> Lwt.return ()
| Some nick ->
set_data state ~force_sync:false nick
{(data state nick) with last_seen = Unix.time ()};
let contact = data state nick in
let to_tell, remaining =
let now = Unix.time() in
contact.to_tell
|> List.partition
(fun t -> match t.tell_after with
| None -> true
| Some f when Float.(now > f) -> true
| Some _ -> false)
in
if not (List.is_empty to_tell) then (
set_data state nick {contact with to_tell = remaining};
);
Lwt_list.iter_s (fun {from=author; on_channel; msg=m; _} ->
C.send_notice ~target:on_channel
~message:(Printf.sprintf "%s: (from %s): %s" nick author m))
(List.rev to_tell)
end
let of_json actions = function
| None ->
Lwt_err.return {actions; map=StrMap.empty; }
| Some j ->
let map = match j with
| `Assoc l ->
l
|> CCList.filter_map (fun (k, j) ->
Option.(contact_of_json j >>= fun c -> Some (k, c)))
|> StrMap.of_list
| _ -> StrMap.empty
in
Lwt_err.return {actions; map}
let to_json (db:state) =
let json = `Assoc (
StrMap.to_list db.map
|> List.map (fun (k, c) -> (k, json_of_contact c))
) in
Some json
let plugin =
let commands state =
[ cmd_tell state;
cmd_tell_at state;
cmd_seen state;
cmd_last state;
cmd_ignore state;
cmd_unignore state;
cmd_ignore_list state;
]
in
Plugin.stateful
~name:"social"
~on_msg:(fun st -> [on_message st])
~of_json ~to_json ~commands ()